]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'development' of github.com:BookStackApp/BookStack into development
authorDan Brown <redacted>
Wed, 23 Nov 2022 00:13:02 +0000 (00:13 +0000)
committerDan Brown <redacted>
Wed, 23 Nov 2022 00:13:02 +0000 (00:13 +0000)
17 files changed:
app/Http/Controllers/SearchController.php
resources/js/components/dropdown.js
resources/js/components/entity-selector.js
resources/js/components/global-search.js [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/page-editor.js
resources/js/services/keyboard-navigation.js [new file with mode: 0644]
resources/js/services/util.js
resources/sass/_animations.scss
resources/sass/_blocks.scss
resources/sass/_forms.scss
resources/sass/_header.scss
resources/views/common/header.blade.php
resources/views/search/parts/entity-selector-list.blade.php [moved from resources/views/search/parts/entity-ajax-list.blade.php with 100% similarity]
resources/views/search/parts/entity-suggestion-list.blade.php [new file with mode: 0644]
routes/web.php
tests/Entity/EntitySearchTest.php

index 699733e377008f508634d9dbbda6755e103c6394..8df5cfafb805514ec9daf728c8832255129d42a7 100644 (file)
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
 
 class SearchController extends Controller
 {
-    protected $searchRunner;
+    protected SearchRunner $searchRunner;
 
     public function __construct(SearchRunner $searchRunner)
     {
@@ -69,7 +69,7 @@ class SearchController extends Controller
      * Search for a list of entities and return a partial HTML response of matching entities.
      * Returns the most popular entities if no search is provided.
      */
-    public function searchEntitiesAjax(Request $request)
+    public function searchForSelector(Request $request)
     {
         $entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
         $searchTerm = $request->get('term', false);
@@ -83,7 +83,25 @@ class SearchController extends Controller
             $entities = (new Popular())->run(20, 0, $entityTypes);
         }
 
-        return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
+        return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
+    }
+
+    /**
+     * Search for a list of entities and return a partial HTML response of matching entities
+     * to be used as a result preview suggestion list for global system searches.
+     */
+    public function searchSuggestions(Request $request)
+    {
+        $searchTerm = $request->get('term', '');
+        $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
+
+        foreach ($entities as $entity) {
+            $entity->setAttribute('preview_content', '');
+        }
+
+        return view('search.parts.entity-suggestion-list', [
+            'entities' => $entities->slice(0, 5)
+        ]);
     }
 
     /**
index 2625ff4de2f38c27d983d14d431e07e3c31fd44b..ed69088b29acae227a9f57557406299a74e71459 100644 (file)
@@ -1,4 +1,5 @@
 import {onSelect} from "../services/dom";
+import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
 import {Component} from "./component";
 
 /**
@@ -17,8 +18,9 @@ export class Dropdown extends Component {
         this.direction = (document.dir === 'rtl') ? 'right' : 'left';
         this.body = document.body;
         this.showing = false;
-        this.setupListeners();
+
         this.hide = this.hide.bind(this);
+        this.setupListeners();
     }
 
     show(event = null) {
@@ -52,7 +54,7 @@ export class Dropdown extends Component {
         }
 
         // Set listener to hide on mouse leave or window click
-        this.menu.addEventListener('mouseleave', this.hide.bind(this));
+        this.menu.addEventListener('mouseleave', this.hide);
         window.addEventListener('click', event => {
             if (!this.menu.contains(event.target)) {
                 this.hide();
@@ -97,33 +99,25 @@ export class Dropdown extends Component {
         this.showing = false;
     }
 
-    getFocusable() {
-        return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
-    }
-
-    focusNext() {
-        const focusable = this.getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
-        let newIndex = currentIndex + 1;
-        if (newIndex >= focusable.length) {
-            newIndex = 0;
-        }
-
-        focusable[newIndex].focus();
-    }
+    setupListeners() {
+        const keyboardNavHandler = new KeyboardNavigationHandler(this.container, (event) => {
+            this.hide();
+            this.toggle.focus();
+            if (!this.bubbleEscapes) {
+                event.stopPropagation();
+            }
+        }, (event) => {
+            if (event.target.nodeName === 'INPUT') {
+                event.preventDefault();
+                event.stopPropagation();
+            }
+            this.hide();
+        });
 
-    focusPrevious() {
-        const focusable = this.getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
-        let newIndex = currentIndex - 1;
-        if (newIndex < 0) {
-            newIndex = focusable.length - 1;
+        if (this.moveMenu) {
+            keyboardNavHandler.shareHandlingToEl(this.menu);
         }
 
-        focusable[newIndex].focus();
-    }
-
-    setupListeners() {
         // Hide menu on option click
         this.container.addEventListener('click', event => {
              const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
@@ -136,39 +130,9 @@ export class Dropdown extends Component {
             event.stopPropagation();
             this.show(event);
             if (event instanceof KeyboardEvent) {
-                this.focusNext();
-            }
-        });
-
-        // Keyboard navigation
-        const keyboardNavigation = event => {
-            if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
-                this.focusNext();
-                event.preventDefault();
-            } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
-                this.focusPrevious();
-                event.preventDefault();
-            } else if (event.key === 'Escape') {
-                this.hide();
-                this.toggle.focus();
-                if (!this.bubbleEscapes) {
-                    event.stopPropagation();
-                }
-            }
-        };
-        this.container.addEventListener('keydown', keyboardNavigation);
-        if (this.moveMenu) {
-            this.menu.addEventListener('keydown', keyboardNavigation);
-        }
-
-        // Hide menu on enter press or escape
-        this.menu.addEventListener('keydown ', event => {
-            if (event.key === 'Enter') {
-                event.preventDefault();
-                event.stopPropagation();
-                this.hide();
+                keyboardNavHandler.focusNext();
             }
         });
     }
 
-}
\ No newline at end of file
+}
index 1496ea89e6da2e118b90ce0ade757df43778be12..1384b33a9660c086882fd768054bf1c4eed73e21 100644 (file)
@@ -115,7 +115,7 @@ export class EntitySelector extends Component {
     }
 
     searchUrl() {
-        return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
+        return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
     }
 
     searchEntities(searchTerm) {
diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js
new file mode 100644 (file)
index 0000000..7bc8a1d
--- /dev/null
@@ -0,0 +1,82 @@
+import {htmlToDom} from "../services/dom";
+import {debounce} from "../services/util";
+import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
+import {Component} from "./component";
+
+/**
+ * Global (header) search box handling.
+ * Mainly to show live results preview.
+ */
+export class GlobalSearch extends Component {
+
+    setup() {
+        this.container = this.$el;
+        this.input = this.$refs.input;
+        this.suggestions = this.$refs.suggestions;
+        this.suggestionResultsWrap = this.$refs.suggestionResults;
+        this.loadingWrap = this.$refs.loading;
+        this.button = this.$refs.button;
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        const updateSuggestionsDebounced = debounce(this.updateSuggestions.bind(this), 200, false);
+
+        // Handle search input changes
+        this.input.addEventListener('input', () => {
+            const value = this.input.value;
+            if (value.length > 0) {
+                this.loadingWrap.style.display = 'block';
+                this.suggestionResultsWrap.style.opacity = '0.5';
+                updateSuggestionsDebounced(value);
+            }  else {
+                this.hideSuggestions();
+            }
+        });
+
+        // Allow double click to show auto-click suggestions
+        this.input.addEventListener('dblclick', () => {
+            this.input.setAttribute('autocomplete', 'on');
+            this.button.focus();
+            this.input.focus();
+        });
+
+        new KeyboardNavigationHandler(this.container, () => {
+            this.hideSuggestions();
+        });
+    }
+
+    /**
+     * @param {String} search
+     */
+    async updateSuggestions(search) {
+        const {data: results} = await window.$http.get('/search/suggest', {term: search});
+        if (!this.input.value) {
+            return;
+        }
+        
+        const resultDom = htmlToDom(results);
+
+        this.suggestionResultsWrap.innerHTML = '';
+        this.suggestionResultsWrap.style.opacity = '1';
+        this.loadingWrap.style.display = 'none';
+        this.suggestionResultsWrap.append(resultDom);
+        if (!this.container.classList.contains('search-active')) {
+            this.showSuggestions();
+        }
+    }
+
+    showSuggestions() {
+        this.container.classList.add('search-active');
+        window.requestAnimationFrame(() => {
+            this.suggestions.classList.add('search-suggestions-animation');
+        })
+    }
+
+    hideSuggestions() {
+        this.container.classList.remove('search-active');
+        this.suggestions.classList.remove('search-suggestions-animation');
+        this.suggestionResultsWrap.innerHTML = '';
+    }
+}
\ No newline at end of file
index 2aac33f7fec4aa9a4aa855de95f6d9b8a09063da..27bce48dbb3d0354ac0dfde6961cd9e7c410974e 100644 (file)
@@ -25,6 +25,7 @@ export {EntitySelector} from "./entity-selector.js"
 export {EntitySelectorPopup} from "./entity-selector-popup.js"
 export {EventEmitSelect} from "./event-emit-select.js"
 export {ExpandToggle} from "./expand-toggle.js"
+export {GlobalSearch} from "./global-search.js"
 export {HeaderMobileToggle} from "./header-mobile-toggle.js"
 export {ImageManager} from "./image-manager.js"
 export {ImagePicker} from "./image-picker.js"
@@ -38,7 +39,7 @@ export {PageDisplay} from "./page-display.js"
 export {PageEditor} from "./page-editor.js"
 export {PagePicker} from "./page-picker.js"
 export {PermissionsTable} from "./permissions-table.js"
-export {Pointer} from "./pointer.js";
+export {Pointer} from "./pointer.js"
 export {Popup} from "./popup.js"
 export {SettingAppColorPicker} from "./setting-app-color-picker.js"
 export {SettingColorPicker} from "./setting-color-picker.js"
@@ -54,5 +55,5 @@ export {TemplateManager} from "./template-manager.js"
 export {ToggleSwitch} from "./toggle-switch.js"
 export {TriLayout} from "./tri-layout.js"
 export {UserSelect} from "./user-select.js"
-export {WebhookEvents} from "./webhook-events";
-export {WysiwygEditor} from "./wysiwyg-editor.js"
\ No newline at end of file
+export {WebhookEvents} from "./webhook-events"
+export {WysiwygEditor} from "./wysiwyg-editor.js"
index d6faabd054129d76e7d94bf6c0a69d430c6688d1..950a5a3b3308ee928039e82a96eac859f20beb53 100644 (file)
@@ -1,5 +1,6 @@
 import * as Dates from "../services/dates";
 import {onSelect} from "../services/dom";
+import {debounce} from "../services/util";
 import {Component} from "./component";
 
 export class PageEditor extends Component {
@@ -66,7 +67,8 @@ export class PageEditor extends Component {
         });
 
         // Changelog controls
-        this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
+        const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
+        this.changelogInput.addEventListener('input', updateChangelogDebounced);
 
         // Draft Controls
         onSelect(this.saveDraftButton, this.saveDraft.bind(this));
@@ -205,4 +207,4 @@ export class PageEditor extends Component {
         }
     }
 
-}
\ No newline at end of file
+}
diff --git a/resources/js/services/keyboard-navigation.js b/resources/js/services/keyboard-navigation.js
new file mode 100644 (file)
index 0000000..9e05ef5
--- /dev/null
@@ -0,0 +1,89 @@
+/**
+ * Handle common keyboard navigation events within a given container.
+ */
+export class KeyboardNavigationHandler {
+
+    /**
+     * @param {Element} container
+     * @param {Function|null} onEscape
+     * @param {Function|null} onEnter
+     */
+    constructor(container, onEscape = null, onEnter = null) {
+        this.containers = [container];
+        this.onEscape = onEscape;
+        this.onEnter = onEnter;
+        container.addEventListener('keydown', this.#keydownHandler.bind(this));
+    }
+
+    /**
+     * Also share the keyboard event handling to the given element.
+     * Only elements within the original container are considered focusable though.
+     * @param {Element} element
+     */
+    shareHandlingToEl(element) {
+        this.containers.push(element);
+        element.addEventListener('keydown', this.#keydownHandler.bind(this));
+    }
+
+    /**
+     * Focus on the next focusable element within the current containers.
+     */
+    focusNext() {
+        const focusable = this.#getFocusable();
+        const currentIndex = focusable.indexOf(document.activeElement);
+        let newIndex = currentIndex + 1;
+        if (newIndex >= focusable.length) {
+            newIndex = 0;
+        }
+
+        focusable[newIndex].focus();
+    }
+
+    /**
+     * Focus on the previous existing focusable element within the current containers.
+     */
+    focusPrevious() {
+        const focusable = this.#getFocusable();
+        const currentIndex = focusable.indexOf(document.activeElement);
+        let newIndex = currentIndex - 1;
+        if (newIndex < 0) {
+            newIndex = focusable.length - 1;
+        }
+
+        focusable[newIndex].focus();
+    }
+
+    /**
+     * @param {KeyboardEvent} event
+     */
+    #keydownHandler(event) {
+        if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
+            this.focusNext();
+            event.preventDefault();
+        } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
+            this.focusPrevious();
+            event.preventDefault();
+        } else if (event.key === 'Escape') {
+            if (this.onEscape) {
+                this.onEscape(event);
+            } else if  (document.activeElement) {
+                document.activeElement.blur();
+            }
+        } else if (event.key === 'Enter' && this.onEnter) {
+            this.onEnter(event);
+        }
+    }
+
+    /**
+     * Get an array of focusable elements within the current containers.
+     * @returns {Element[]}
+     */
+    #getFocusable() {
+        const focusable = [];
+        const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
+        for (const container of this.containers) {
+            focusable.push(...container.querySelectorAll(selector))
+        }
+        return focusable;
+    }
+}
\ No newline at end of file
index de2ca20c13eb934b00a475b817489e9fa29d9670..1a56ebf6ce8595a29cab4f48fbd9422f9a9649a2 100644 (file)
@@ -6,9 +6,9 @@
  * N milliseconds. If `immediate` is passed, trigger the function on the
  * leading edge, instead of the trailing.
  * @attribution https://davidwalsh.name/javascript-debounce-function
- * @param func
- * @param wait
- * @param immediate
+ * @param {Function} func
+ * @param {Number} wait
+ * @param {Boolean} immediate
  * @returns {Function}
  */
 export function debounce(func, wait, immediate) {
index 85fd96206393a64581c08b829587d01cc09b3e06..eb9f4e767a4e9cf604287c411b8cee55299b4faa 100644 (file)
   }
 }
 
-.anim.searchResult {
-  opacity: 0;
-  transform: translate3d(580px, 0, 0);
-  animation-name: searchResult;
-  animation-duration: 220ms;
+.search-suggestions-animation{
+  animation-name: searchSuggestions;
+  animation-duration: 120ms;
   animation-fill-mode: forwards;
   animation-timing-function: cubic-bezier(.62, .28, .23, .99);
 }
 
-@keyframes searchResult {
+@keyframes searchSuggestions {
   0% {
-    opacity: 0;
-    transform: translate3d(400px, 0, 0);
+    opacity: .5;
+    transform: scale(0.9);
   }
   100% {
     opacity: 1;
-    transform: translate3d(0, 0, 0);
+    transform: scale(1);
   }
 }
 
index 6058add828e2eccef4d46ca8465439a78277c5db..37b7b403b2076b338d8f8f1d2895c0f9fc09f41a 100644 (file)
 .card-title a {
   line-height: 1;
 }
-.card-footer-link {
+.card-footer-link, button.card-footer-link  {
   display: block;
   padding: $-s $-m;
   line-height: 1;
   border-top: 1px solid;
+  width: 100%;
+  text-align: left;
   @include lightDark(border-color, #DDD, #555);
   border-radius: 0 0 3px 3px;
   font-size: 0.9em;
     text-decoration: none;
     @include lightDark(background-color, #f2f2f2, #2d2d2d);
   }
+  &:focus {
+    @include lightDark(background-color, #eee, #222);
+    outline: 1px dotted #666;
+    outline-offset: -2px;
+  }
 }
 
 .card.border-card {
index f341ce48683f8cc90cc01288ef61a36d2a548aa5..57799faef2dad3a1f671ebeb15dd50ba4059d2ba 100644 (file)
@@ -412,7 +412,7 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
 .search-box {
   max-width: 100%;
   position: relative;
-  button {
+  button[tabindex="-1"] {
     background-color: transparent;
     border: none;
     @include lightDark(color, #666, #AAA);
index 923f026c2663f3f9f09d301b89cb947c3c03188e..aa560e8e050e3556b636d5d9bdae6a33d6080f5e 100644 (file)
@@ -108,21 +108,6 @@ header .search-box {
       border: 1px solid rgba(255, 255, 255, 0.4);
     }
   }
-  button {
-    z-index: 1;
-    left: 16px;
-    top: 10px;
-    color: #FFF;
-    opacity: 0.6;
-    @include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
-    @include rtl {
-      left: auto;
-      right: 16px;
-    }
-    svg {
-      margin-block-end: 0;
-    }
-  }
   input::placeholder {
     color: #FFF;
     opacity: 0.6;
@@ -130,10 +115,67 @@ header .search-box {
   @include between($l, $xl) {
     max-width: 200px;
   }
-  &:focus-within button {
+  &:focus-within #header-search-box-button {
     opacity: 1;
   }
 }
+#header-search-box-button {
+  z-index: 1;
+  inset-inline-start: 16px;
+  top: 10px;
+  color: #FFF;
+  opacity: 0.6;
+  @include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
+  svg {
+    margin-inline-end: 0;
+  }
+}
+
+.global-search-suggestions {
+  display: none;
+  position: absolute;
+  top: -$-s;
+  left: 0;
+  right: 0;
+  z-index: -1;
+  margin-left: -$-xxl;
+  margin-right: -$-xxl;
+  padding-top: 56px;
+  border-radius: 3px;
+  box-shadow: $bs-hover;
+  transform-origin: top center;
+  opacity: .5;
+  transform: scale(0.9);
+  .entity-item-snippet p  {
+    display: none;
+  }
+  .entity-item-snippet {
+    font-size: .8rem;
+  }
+  .entity-list-item-name {
+    font-size: .9rem;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    overflow: hidden;
+  }
+  .global-search-loading {
+    position: absolute;
+    width: 100%;
+  }
+}
+header .search-box.search-active:focus-within {
+  .global-search-suggestions {
+    display: block;
+  }
+  input {
+    @include lightDark(background-color, #EEE, #333);
+    @include lightDark(border-color, #DDD, #111);
+  }
+  #header-search-box-button, input {
+    @include lightDark(color, #444, #AAA);
+  }
+}
 
 .logo {
   display: inline-flex;
index 9fe97b853ae53b7ca491287a0e1b28b9c34c0323..71b73215b7ebb9a59252904536407dd1f3630c95 100644 (file)
 
         <div class="flex-container-column items-center justify-center hide-under-l">
             @if (hasAppAccess())
-            <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
-                <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
-                <input id="header-search-box-input" type="text" name="term"
+            <form component="global-search" action="{{ url('/search') }}" method="GET" class="search-box" role="search">
+                <button id="header-search-box-button"
+                        refs="global-search@button"
+                        type="submit"
+                        aria-label="{{ trans('common.search') }}"
+                        tabindex="-1">@icon('search')</button>
+                <input id="header-search-box-input"
+                       refs="global-search@input"
+                       type="text"
+                       name="term"
                        data-shortcut="global_search"
+                       autocomplete="off"
                        aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
-                       value="{{ isset($searchTerm) ? $searchTerm : '' }}">
+                       value="{{ $searchTerm ?? '' }}">
+                <div refs="global-search@suggestions" class="global-search-suggestions card">
+                    <div refs="global-search@loading" class="text-center px-m global-search-loading">@include('common.loading-icon')</div>
+                    <div refs="global-search@suggestion-results" class="px-m"></div>
+                    <button class="text-button card-footer-link" type="submit">{{ trans('common.view_all') }}</button>
+                </div>
             </form>
             @endif
         </div>
diff --git a/resources/views/search/parts/entity-suggestion-list.blade.php b/resources/views/search/parts/entity-suggestion-list.blade.php
new file mode 100644 (file)
index 0000000..4a8e838
--- /dev/null
@@ -0,0 +1,21 @@
+<div class="entity-list">
+    @if(count($entities) > 0)
+        @foreach($entities as $index => $entity)
+
+            @include('entities.list-item', [
+                'entity' => $entity,
+                'showPath' => true,
+                'locked' => false,
+            ])
+        
+            @if($index !== count($entities) - 1)
+                <hr>
+            @endif
+
+        @endforeach
+    @else
+        <div class="text-muted px-m py-m">
+            {{ trans('common.no_items') }}
+        </div>
+    @endif
+</div>
\ No newline at end of file
index de913c543ab3a6eb7e4d542b027618f91b22c8ce..107484939488f5c817ef1b835fa35ae6e1b3b834 100644 (file)
@@ -184,8 +184,6 @@ Route::middleware('auth')->group(function () {
     Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
     Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
 
-    Route::get('/ajax/search/entities', [SearchController::class, 'searchEntitiesAjax']);
-
     // Comments
     Route::post('/comment/{pageId}', [CommentController::class, 'savePageComment']);
     Route::put('/comment/{id}', [CommentController::class, 'update']);
@@ -199,6 +197,8 @@ Route::middleware('auth')->group(function () {
     Route::get('/search/book/{bookId}', [SearchController::class, 'searchBook']);
     Route::get('/search/chapter/{bookId}', [SearchController::class, 'searchChapter']);
     Route::get('/search/entity/siblings', [SearchController::class, 'searchSiblings']);
+    Route::get('/search/entity-selector', [SearchController::class, 'searchForSelector']);
+    Route::get('/search/suggest', [SearchController::class, 'searchSuggestions']);
 
     // User Search
     Route::get('/search/users/select', [UserSearchController::class, 'forSelect']);
index c309f2167954b3f99ce344c13fef926cb2280946..2650b6743cd015f5c85c027fdb6cffcf12de6670 100644 (file)
@@ -190,7 +190,7 @@ class EntitySearchTest extends TestCase
         $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name);
     }
 
-    public function test_ajax_entity_search()
+    public function test_entity_selector_search()
     {
         $page = $this->entities->newPage(['name' => 'my ajax search test', 'html' => 'ajax test']);
         $notVisitedPage = $this->entities->page();
@@ -198,38 +198,38 @@ class EntitySearchTest extends TestCase
         // Visit the page to make popular
         $this->asEditor()->get($page->getUrl());
 
-        $normalSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
+        $normalSearch = $this->get('/search/entity-selector?term=' . urlencode($page->name));
         $normalSearch->assertSee($page->name);
 
-        $bookSearch = $this->get('/ajax/search/entities?types=book&term=' . urlencode($page->name));
+        $bookSearch = $this->get('/search/entity-selector?types=book&term=' . urlencode($page->name));
         $bookSearch->assertDontSee($page->name);
 
-        $defaultListTest = $this->get('/ajax/search/entities');
+        $defaultListTest = $this->get('/search/entity-selector');
         $defaultListTest->assertSee($page->name);
         $defaultListTest->assertDontSee($notVisitedPage->name);
     }
 
-    public function test_ajax_entity_search_shows_breadcrumbs()
+    public function test_entity_selector_search_shows_breadcrumbs()
     {
         $chapter = $this->entities->chapter();
         $page = $chapter->pages->first();
         $this->asEditor();
 
-        $pageSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
+        $pageSearch = $this->get('/search/entity-selector?term=' . urlencode($page->name));
         $pageSearch->assertSee($page->name);
         $pageSearch->assertSee($chapter->getShortName(42));
         $pageSearch->assertSee($page->book->getShortName(42));
 
-        $chapterSearch = $this->get('/ajax/search/entities?term=' . urlencode($chapter->name));
+        $chapterSearch = $this->get('/search/entity-selector?term=' . urlencode($chapter->name));
         $chapterSearch->assertSee($chapter->name);
         $chapterSearch->assertSee($chapter->book->getShortName(42));
     }
 
-    public function test_ajax_entity_search_reflects_items_without_permission()
+    public function test_entity_selector_search_reflects_items_without_permission()
     {
         $page = $this->entities->page();
         $baseSelector = 'a[data-entity-type="page"][data-entity-id="' . $page->id . '"]';
-        $searchUrl = '/ajax/search/entities?permission=update&term=' . urlencode($page->name);
+        $searchUrl = '/search/entity-selector?permission=update&term=' . urlencode($page->name);
 
         $resp = $this->asEditor()->get($searchUrl);
         $this->withHtml($resp)->assertElementContains($baseSelector, $page->name);
@@ -457,4 +457,25 @@ class EntitySearchTest extends TestCase
         $this->withHtml($resp)->assertElementExists('form input[name="filters[updated_by]"][value="me"][checked="checked"]');
         $this->withHtml($resp)->assertElementExists('form input[name="filters[created_by]"][value="me"][checked="checked"]');
     }
+
+    public function test_search_suggestion_endpoint()
+    {
+        $this->entities->newPage(['name' => 'My suggestion page', 'html' => '<p>My supercool suggestion page</p>']);
+
+        // Test specific search
+        $resp = $this->asEditor()->get('/search/suggest?term="supercool+suggestion"');
+        $resp->assertSee('My suggestion page');
+        $resp->assertDontSee('My supercool suggestion page');
+        $resp->assertDontSee('No items available');
+        $this->withHtml($resp)->assertElementCount('a', 1);
+
+        // Test search limit
+        $resp = $this->asEditor()->get('/search/suggest?term=et');
+        $this->withHtml($resp)->assertElementCount('a', 5);
+
+        // Test empty state
+        $resp = $this->asEditor()->get('/search/suggest?term=spaghettisaurusrex');
+        $this->withHtml($resp)->assertElementCount('a', 0);
+        $resp->assertSee('No items available');
+    }
 }