@@ -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 && / E s c a p e / 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);
8571022documentReady ( scrollToActive ) ;
8581023documentReady ( addTOCInteractivity ) ;
8591024documentReady ( setupSearchButtons ) ;
1025+ documentReady ( setupSearchAsYouType ) ;
8601026documentReady ( setupMobileSidebarKeyboardHandlers ) ;
8611027
8621028// Determining whether an element has scrollable content depends on stylesheets,
0 commit comments