diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 699733e37..8df5cfafb 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -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) + ]); } /** diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 2625ff4de..ed69088b2 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -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(); - } - - 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(); - } - 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(); + }); + + if (this.moveMenu) { + keyboardNavHandler.shareHandlingToEl(this.menu); + } + // 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 +} diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index 1496ea89e..1384b33a9 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -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 index 000000000..9f063f398 --- /dev/null +++ b/resources/js/components/global-search.js @@ -0,0 +1,82 @@ +import {htmlToDom} from "../services/dom"; +import {debounce} from "../services/util"; +import {KeyboardNavigationHandler} from "../services/keyboard-navigation"; + +/** + * @extends {Component} + */ +class GlobalSearch { + + 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 = ''; + } +} + +export default GlobalSearch; \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 2aac33f7f..f4e6da0e4 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -55,4 +55,4 @@ 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 {WysiwygEditor} from "./wysiwyg-editor.js" diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index d6faabd05..950a5a3b3 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -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 index 000000000..9e05ef528 --- /dev/null +++ b/resources/js/services/keyboard-navigation.js @@ -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 diff --git a/resources/js/services/util.js b/resources/js/services/util.js index de2ca20c1..1a56ebf6c 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -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) { diff --git a/resources/sass/_animations.scss b/resources/sass/_animations.scss index 85fd96206..eb9f4e767 100644 --- a/resources/sass/_animations.scss +++ b/resources/sass/_animations.scss @@ -16,23 +16,21 @@ } } -.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); } } diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 6058add82..37b7b403b 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -86,11 +86,13 @@ .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; @@ -99,6 +101,11 @@ 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 { diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index f341ce486..57799faef 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -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); diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index 923f026c2..aa560e8e0 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -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; diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 9fe97b853..71b73215b 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -19,12 +19,25 @@
@if (hasAppAccess()) - @endif
diff --git a/resources/views/search/parts/entity-ajax-list.blade.php b/resources/views/search/parts/entity-selector-list.blade.php similarity index 100% rename from resources/views/search/parts/entity-ajax-list.blade.php rename to resources/views/search/parts/entity-selector-list.blade.php 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 index 000000000..4a8e83880 --- /dev/null +++ b/resources/views/search/parts/entity-suggestion-list.blade.php @@ -0,0 +1,21 @@ +
+ @if(count($entities) > 0) + @foreach($entities as $index => $entity) + + @include('entities.list-item', [ + 'entity' => $entity, + 'showPath' => true, + 'locked' => false, + ]) + + @if($index !== count($entities) - 1) +
+ @endif + + @endforeach + @else +
+ {{ trans('common.no_items') }} +
+ @endif +
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index de913c543..107484939 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index c309f2167..2650b6743 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -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' => '

My supercool suggestion page

']); + + // 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'); + } }