class SearchController extends Controller
{
- protected $searchRunner;
+ protected SearchRunner $searchRunner;
public function __construct(SearchRunner $searchRunner)
{
* 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);
$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)
+ ]);
}
/**
import {onSelect} from "../services/dom";
+import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
import {Component} from "./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) {
}
// 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();
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'));
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
+}
}
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) {
--- /dev/null
+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
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"
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"
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"
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 {
});
// 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));
}
}
-}
\ No newline at end of file
+}
--- /dev/null
+/**
+ * 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
* 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) {
}
}
-.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);
}
}
.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 {
.search-box {
max-width: 100%;
position: relative;
- button {
+ button[tabindex="-1"] {
background-color: transparent;
border: none;
@include lightDark(color, #666, #AAA);
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;
@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;
<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>
--- /dev/null
+<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
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']);
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']);
$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();
// 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);
$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');
+ }
}