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

Merged
merged 25 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
45b6447
Add a function to set up search-as-you-type
Jan 4, 2025
1a1112b
Update the layout to load search-related scripts
Jan 4, 2025
aee0d92
Don't interfere with search page and bail if Search class isn't found
Jan 4, 2025
ead8b4e
Add more search-as-you-type logic (not working yet)
Jan 4, 2025
37143ed
Start search-as-you-type styles (not working yet)
Jan 4, 2025
bdf3385
Get basic search-as-you-type UX working
Jan 4, 2025
e531e70
Hide the search results container until it has content
Jan 4, 2025
c9b5daa
Remove search results when query is empty
Jan 6, 2025
1f522ed
Fix top position of search dialog
Jan 6, 2025
1db1290
Make the feature configurable
Jan 6, 2025
710b383
Add a11y test
Jan 7, 2025
66c93ca
Update comments
Jan 7, 2025
00bb0af
Add docs
Jan 7, 2025
5b06319
Add comments to a11y test
Jan 7, 2025
c203489
[pre-commit.ci] Automatic linting and formatting fixes
pre-commit-ci[bot] Jan 7, 2025
8f3de69
Add docstring
Jan 7, 2025
f40c6ff
Fix docstring length
Jan 7, 2025
5132816
Merge branch 'pydata:main' into search
kaycebasques Jan 8, 2025
19bab1c
Make the a11y test more robust
Jan 9, 2025
4388a76
[pre-commit.ci] Automatic linting and formatting fixes
pre-commit-ci[bot] Jan 9, 2025
07d4b62
Make the link to Search class more robust
Jan 9, 2025
49025fa
Temporary to change to verify dirhtml
Jan 9, 2025
78abe78
Fix keyboard navigation stuff
Jan 12, 2025
23d2db2
Revert docs-dev script back to its original state
Jan 12, 2025
be4ea8d
[pre-commit.ci] Automatic linting and formatting fixes
pre-commit-ci[bot] Jan 12, 2025
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
166 changes: 166 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 @@ -261,6 +261,7 @@ var addEventListenerForSearchKeyboard = () => {
// also allow Escape key to hide (but not show) the dynamic search field
else if (document.activeElement === input && /Escape/i.test(event.key)) {
toggleSearchField();
resetSearchAsYouTypeResults();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the user types a query into the search box and then presses Esc to close the modal, then the last inline search results should be removed.

}
},
true,
Expand Down Expand Up @@ -332,6 +333,170 @@ 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 +354 to +357
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/6678e357048ea1767daaad68e7e0569786f3b458/sphinx/themes/basic/static/searchtools.js#L181
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("empty");
// Remove the container element from the tab order. Individual search
// results are still focusable.
results.tabIndex = -1;
drammock marked this conversation as resolved.
Show resolved Hide resolved
// When focus is on a search result, make sure that pressing Escape closes
// the search modal.
results.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
drammock marked this conversation as resolved.
Show resolved Hide resolved
event.preventDefault();
event.stopPropagation();
toggleSearchField();
resetSearchAsYouTypeResults();
}
});
// 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) => {
link.tabIndex = 0; // Use natural tab order for search results.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Attempting to make sure that inline search results links are focusable here

// 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 +1022,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
31 changes: 31 additions & 0 deletions tests/test_a11y.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,34 @@ 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:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@drammock re: the search results container itself receiving focus, it seems like this test should have failed? Does CI not run the a11y tests on different OS / browser permutations?

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah, seems it should have 🤷🏻
CI only runs a11y tests in one job (ubuntu-latest, py3.12).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added #2095 to track the issue. I assume we'll want to address that in a separate PR

"""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 with the keyboard.
query_input.press("Tab")
# Make sure that the first inline search result is focused.
actual_focused_content = page.evaluate("document.activeElement.textContent")
first_result_selector = "#search-results .search li:first-child a"
expected_focused_content = page.evaluate(
f"document.querySelector('{first_result_selector}').textContent"
)
assert actual_focused_content == expected_focused_content
Loading