From 19a792bc1257ad60cad614d705f0861a2b0e4829 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 14 Nov 2022 10:24:14 +0000 Subject: [PATCH 1/7] Started on a live-preview on global search input --- app/Http/Controllers/SearchController.php | 2 +- resources/js/components/global-search.js | 47 +++++++++++++++++++++++ resources/js/components/index.js | 2 + resources/sass/_blocks.scss | 4 +- resources/sass/_components.scss | 27 +++++++++++++ resources/sass/_forms.scss | 2 +- resources/sass/_header.scss | 32 +++++++-------- resources/views/common/header.blade.php | 13 +++++-- 8 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 resources/js/components/global-search.js diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 699733e37..8fcde4a6d 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) { diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js new file mode 100644 index 000000000..b351278d6 --- /dev/null +++ b/resources/js/components/global-search.js @@ -0,0 +1,47 @@ +/** + * @extends {Component} + */ +import {htmlToDom} from "../services/dom"; + +class GlobalSearch { + + setup() { + this.input = this.$refs.input; + this.suggestions = this.$refs.suggestions; + this.suggestionResultsWrap = this.$refs.suggestionResults; + + this.setupListeners(); + } + + setupListeners() { + this.input.addEventListener('input', () => { + const value = this.input.value; + if (value.length > 0) { + this.updateSuggestions(value); + } else { + this.hideSuggestions(); + } + }); + } + + async updateSuggestions(search) { + const {data: results} = await window.$http.get('/ajax/search/entities', {term: search, count: 5}); + const resultDom = htmlToDom(results); + + const childrenToTrim = Array.from(resultDom.children).slice(9); + for (const child of childrenToTrim) { + child.remove(); + } + + this.suggestions.style.display = 'block'; + this.suggestionResultsWrap.innerHTML = ''; + this.suggestionResultsWrap.append(resultDom); + } + + hideSuggestions() { + this.suggestions.style.display = null; + 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 9f801668e..41dbf4de5 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -57,6 +57,7 @@ import triLayout from "./tri-layout.js" import userSelect from "./user-select.js" import webhookEvents from "./webhook-events"; import wysiwygEditor from "./wysiwyg-editor.js" +import globalSearch from "./global-search"; const componentMapping = { "add-remove-rows": addRemoveRows, @@ -86,6 +87,7 @@ const componentMapping = { "entity-selector-popup": entitySelectorPopup, "event-emit-select": eventEmitSelect, "expand-toggle": expandToggle, + "global-search": globalSearch, "header-mobile-toggle": headerMobileToggle, "homepage-control": homepageControl, "image-manager": imageManager, diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 6058add82..302e7ed4e 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; diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 66d76aaa2..31ec6a2c0 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1010,4 +1010,31 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border: 1px solid #b4b4b4; box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; color: #333; +} + +.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; + .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; + } } \ No newline at end of file diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 7de8a9d7d..709db3bd6 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..e0b494e77 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,25 @@ 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; + 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; + } +} .logo { display: inline-flex; diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 9fe97b853..1340be26d 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -19,12 +19,19 @@
@if (hasAppAccess()) - @endif
From 851ab47f8adb2d04555c28fceae371e038d90a40 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 20 Nov 2022 21:50:59 +0000 Subject: [PATCH 2/7] Fixed input styles in search preview mode, added animation Also added JS handlers for hiding the suggestions --- resources/js/components/global-search.js | 27 ++++++++++++++-- resources/sass/_animations.scss | 16 ++++----- resources/sass/_components.scss | 27 ---------------- resources/sass/_header.scss | 41 ++++++++++++++++++++++++ resources/views/common/header.blade.php | 1 + 5 files changed, 74 insertions(+), 38 deletions(-) diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index b351278d6..0af3ed375 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -6,6 +6,7 @@ import {htmlToDom} from "../services/dom"; class GlobalSearch { setup() { + this.container = this.$el; this.input = this.$refs.input; this.suggestions = this.$refs.suggestions; this.suggestionResultsWrap = this.$refs.suggestionResults; @@ -14,6 +15,8 @@ class GlobalSearch { } setupListeners() { + this.hideOnOuterEventListener = this.hideOnOuterEventListener.bind(this); + this.input.addEventListener('input', () => { const value = this.input.value; if (value.length > 0) { @@ -33,15 +36,35 @@ class GlobalSearch { child.remove(); } - this.suggestions.style.display = 'block'; this.suggestionResultsWrap.innerHTML = ''; this.suggestionResultsWrap.append(resultDom); + if (!this.container.classList.contains('search-active')) { + this.showSuggestions(); + } + } + + showSuggestions() { + this.container.classList.add('search-active'); + document.addEventListener('click', this.hideOnOuterEventListener); + document.addEventListener('focusin', this.hideOnOuterEventListener); + window.requestAnimationFrame(() => { + this.suggestions.classList.add('search-suggestions-animation'); + }) } hideSuggestions() { - this.suggestions.style.display = null; + this.container.classList.remove('search-active'); + this.suggestions.classList.remove('search-suggestions-animation'); this.suggestionResultsWrap.innerHTML = ''; + document.removeEventListener('click', this.hideOnOuterEventListener); + document.removeEventListener('focusin', this.hideOnOuterEventListener); } + + hideOnOuterEventListener(event) { + if (!this.container.contains(event.target)) { + this.hideSuggestions(); + } + }; } export default GlobalSearch; \ No newline at end of file diff --git a/resources/sass/_animations.scss b/resources/sass/_animations.scss index 85fd96206..ca4977a89 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: 180ms; 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/_components.scss b/resources/sass/_components.scss index 31ec6a2c0..66d76aaa2 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1010,31 +1010,4 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border: 1px solid #b4b4b4; box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; color: #333; -} - -.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; - .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; - } } \ No newline at end of file diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index e0b494e77..5e57047df 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -135,6 +135,47 @@ header .search-box { } } +.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; + } +} +.search-active .global-search-suggestions { + display: block; +} +header .search-box.search-active input { + background-color: #EEE; + color: #444; + border-color: #DDD; +} +header .search-box.search-active #header-search-box-button { + color: #444; +} + .logo { display: inline-flex; padding: ($-s - 6px) $-s; diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 1340be26d..5d94deee7 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -26,6 +26,7 @@ type="text" name="term" data-shortcut="global_search" + autocomplete="off" aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}" value="{{ $searchTerm ?? '' }}">
From 2c1f20969ad3042b035425b488a754b60e6483a4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 20 Nov 2022 21:53:53 +0000 Subject: [PATCH 3/7] Replaced JS logic with CSS focus-within logic --- resources/js/components/global-search.js | 12 ------------ resources/sass/_header.scss | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index 0af3ed375..52c767642 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -15,8 +15,6 @@ class GlobalSearch { } setupListeners() { - this.hideOnOuterEventListener = this.hideOnOuterEventListener.bind(this); - this.input.addEventListener('input', () => { const value = this.input.value; if (value.length > 0) { @@ -45,8 +43,6 @@ class GlobalSearch { showSuggestions() { this.container.classList.add('search-active'); - document.addEventListener('click', this.hideOnOuterEventListener); - document.addEventListener('focusin', this.hideOnOuterEventListener); window.requestAnimationFrame(() => { this.suggestions.classList.add('search-suggestions-animation'); }) @@ -56,15 +52,7 @@ class GlobalSearch { this.container.classList.remove('search-active'); this.suggestions.classList.remove('search-suggestions-animation'); this.suggestionResultsWrap.innerHTML = ''; - document.removeEventListener('click', this.hideOnOuterEventListener); - document.removeEventListener('focusin', this.hideOnOuterEventListener); } - - hideOnOuterEventListener(event) { - if (!this.container.contains(event.target)) { - this.hideSuggestions(); - } - }; } export default GlobalSearch; \ No newline at end of file diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index 5e57047df..522855b2e 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -164,7 +164,7 @@ header .search-box { overflow: hidden; } } -.search-active .global-search-suggestions { +.search-active:focus-within .global-search-suggestions { display: block; } header .search-box.search-active input { From c617190905fe74cae33bcbd1e90ec2340f68d9b7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 20 Nov 2022 22:20:31 +0000 Subject: [PATCH 4/7] Added global search input debounce and loading indicator --- resources/js/components/global-search.js | 29 +++++++++++++++++++++--- resources/js/services/util.js | 6 ++--- resources/sass/_header.scss | 28 ++++++++++++++--------- resources/views/common/header.blade.php | 1 + 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index 52c767642..b3719c8fd 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -1,8 +1,9 @@ +import {htmlToDom} from "../services/dom"; +import {debounce} from "../services/util"; + /** * @extends {Component} */ -import {htmlToDom} from "../services/dom"; - class GlobalSearch { setup() { @@ -10,23 +11,43 @@ class GlobalSearch { this.input = this.$refs.input; this.suggestions = this.$refs.suggestions; this.suggestionResultsWrap = this.$refs.suggestionResults; + this.loadingWrap = this.$refs.loading; 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.updateSuggestions(value); + 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.input.blur(); + this.input.focus(); + }) } + /** + * @param {String} search + */ async updateSuggestions(search) { const {data: results} = await window.$http.get('/ajax/search/entities', {term: search, count: 5}); + if (!this.input.value) { + return; + } + const resultDom = htmlToDom(results); const childrenToTrim = Array.from(resultDom.children).slice(9); @@ -35,6 +56,8 @@ class GlobalSearch { } 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(); 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/_header.scss b/resources/sass/_header.scss index 522855b2e..ca2ab83a4 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -131,7 +131,7 @@ header .search-box { right: 16px; } svg { - margin-block-end: 0; + margin-inline-end: 0; } } @@ -163,17 +163,23 @@ header .search-box { -webkit-line-clamp: 2; overflow: hidden; } + .global-search-loading { + position: absolute; + width: 100%; + } } -.search-active:focus-within .global-search-suggestions { - display: block; -} -header .search-box.search-active input { - background-color: #EEE; - color: #444; - border-color: #DDD; -} -header .search-box.search-active #header-search-box-button { - color: #444; +header .search-box.search-active:focus-within { + .global-search-suggestions { + display: block; + } + input { + background-color: #EEE; + color: #444; + border-color: #DDD; + } + #header-search-box-button { + color: #444; + } } .logo { diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 5d94deee7..d5f4fa224 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -30,6 +30,7 @@ aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}" value="{{ $searchTerm ?? '' }}">
+
@include('common.loading-icon')
From e7e83a4109ab95e58f3772a2d9cd759b43ddbd3f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Nov 2022 10:29:12 +0000 Subject: [PATCH 5/7] Added new endpoint for search suggestions --- app/Http/Controllers/SearchController.php | 22 ++++++++++- resources/js/components/entity-selector.js | 2 +- resources/js/components/global-search.js | 10 ++--- resources/sass/_animations.scss | 2 +- resources/views/common/header.blade.php | 6 ++- ...ade.php => entity-selector-list.blade.php} | 0 .../parts/entity-suggestion-list.blade.php | 21 ++++++++++ routes/web.php | 4 +- tests/Entity/EntitySearchTest.php | 39 ++++++++++++++----- 9 files changed, 83 insertions(+), 23 deletions(-) rename resources/views/search/parts/{entity-ajax-list.blade.php => entity-selector-list.blade.php} (100%) create mode 100644 resources/views/search/parts/entity-suggestion-list.blade.php diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 8fcde4a6d..8df5cfafb 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -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/entity-selector.js b/resources/js/components/entity-selector.js index e2596998a..2f009b2da 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -115,7 +115,7 @@ class EntitySelector { } 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 index b3719c8fd..c940ad983 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -12,6 +12,7 @@ class GlobalSearch { this.suggestions = this.$refs.suggestions; this.suggestionResultsWrap = this.$refs.suggestionResults; this.loadingWrap = this.$refs.loading; + this.button = this.$refs.button; this.setupListeners(); } @@ -34,7 +35,7 @@ class GlobalSearch { // Allow double click to show auto-click suggestions this.input.addEventListener('dblclick', () => { this.input.setAttribute('autocomplete', 'on'); - this.input.blur(); + this.button.focus(); this.input.focus(); }) } @@ -43,18 +44,13 @@ class GlobalSearch { * @param {String} search */ async updateSuggestions(search) { - const {data: results} = await window.$http.get('/ajax/search/entities', {term: search, count: 5}); + const {data: results} = await window.$http.get('/search/suggest', {term: search}); if (!this.input.value) { return; } const resultDom = htmlToDom(results); - const childrenToTrim = Array.from(resultDom.children).slice(9); - for (const child of childrenToTrim) { - child.remove(); - } - this.suggestionResultsWrap.innerHTML = ''; this.suggestionResultsWrap.style.opacity = '1'; this.loadingWrap.style.display = 'none'; diff --git a/resources/sass/_animations.scss b/resources/sass/_animations.scss index ca4977a89..eb9f4e767 100644 --- a/resources/sass/_animations.scss +++ b/resources/sass/_animations.scss @@ -18,7 +18,7 @@ .search-suggestions-animation{ animation-name: searchSuggestions; - animation-duration: 180ms; + animation-duration: 120ms; animation-fill-mode: forwards; animation-timing-function: cubic-bezier(.62, .28, .23, .99); } diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index d5f4fa224..71b73215b 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -20,7 +20,11 @@
@if (hasAppAccess())
\ 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'); + } } From 0e528986abb94be2160b8d9c9d72a883a41bb60b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Nov 2022 17:35:19 +0000 Subject: [PATCH 6/7] Extracted keyboard nav. from dropdowns to share w/ search --- resources/js/components/dropdown.js | 82 +++++------------- resources/js/components/global-search.js | 7 +- resources/js/components/page-editor.js | 4 +- resources/js/services/keyboard-navigation.js | 89 ++++++++++++++++++++ resources/sass/_blocks.scss | 5 ++ 5 files changed, 126 insertions(+), 61 deletions(-) create mode 100644 resources/js/services/keyboard-navigation.js diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 781f90860..514a6c138 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"; /** * Dropdown @@ -17,8 +18,9 @@ class DropDown { 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 @@ class DropDown { } // 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 @@ class DropDown { 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,37 +130,7 @@ class DropDown { 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(); } }); } diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index c940ad983..9f063f398 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -1,5 +1,6 @@ import {htmlToDom} from "../services/dom"; import {debounce} from "../services/util"; +import {KeyboardNavigationHandler} from "../services/keyboard-navigation"; /** * @extends {Component} @@ -37,7 +38,11 @@ class GlobalSearch { this.input.setAttribute('autocomplete', 'on'); this.button.focus(); this.input.focus(); - }) + }); + + new KeyboardNavigationHandler(this.container, () => { + this.hideSuggestions(); + }); } /** diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index ce123e987..14f6b5b3e 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"; /** * Page Editor @@ -69,7 +70,8 @@ class PageEditor { }); // 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)); 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/sass/_blocks.scss b/resources/sass/_blocks.scss index 302e7ed4e..37b7b403b 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -101,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 { From fcff2068533b3d880d7f9e6a90b8c424803a3d00 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 23 Nov 2022 00:05:24 +0000 Subject: [PATCH 7/7] Adjusted global search preview for dark mode --- resources/sass/_header.scss | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index ca2ab83a4..aa560e8e0 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -121,15 +121,11 @@ header .search-box { } #header-search-box-button { z-index: 1; - left: 16px; + inset-inline-start: 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-inline-end: 0; } @@ -173,12 +169,11 @@ header .search-box.search-active:focus-within { display: block; } input { - background-color: #EEE; - color: #444; - border-color: #DDD; + @include lightDark(background-color, #EEE, #333); + @include lightDark(border-color, #DDD, #111); } - #header-search-box-button { - color: #444; + #header-search-box-button, input { + @include lightDark(color, #444, #AAA); } }