@@ -261,6 +261,7 @@ var addEventListenerForSearchKeyboard = () => {
261
261
// also allow Escape key to hide (but not show) the dynamic search field
262
262
else if ( document . activeElement === input && / E s c a p e / i. test ( event . key ) ) {
263
263
toggleSearchField ( ) ;
264
+ resetSearchAsYouTypeResults ( ) ;
264
265
}
265
266
} ,
266
267
true ,
@@ -332,6 +333,170 @@ var setupSearchButtons = () => {
332
333
searchDialog . addEventListener ( "click" , closeDialogOnBackdropClick ) ;
333
334
} ;
334
335
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
+
335
500
/*******************************************************************************
336
501
* Version Switcher
337
502
* Note that this depends on two variables existing that are defined in
@@ -857,6 +1022,7 @@ documentReady(addModeListener);
857
1022
documentReady ( scrollToActive ) ;
858
1023
documentReady ( addTOCInteractivity ) ;
859
1024
documentReady ( setupSearchButtons ) ;
1025
+ documentReady ( setupSearchAsYouType ) ;
860
1026
documentReady ( setupMobileSidebarKeyboardHandlers ) ;
861
1027
862
1028
// Determining whether an element has scrollable content depends on stylesheets,
0 commit comments