Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions app/Http/Controllers/SearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class SearchController extends Controller
{
protected $searchRunner;
protected SearchRunner $searchRunner;

public function __construct(SearchRunner $searchRunner)
{
Expand Down Expand Up @@ -69,7 +69,7 @@ public function searchChapter(Request $request, int $chapterId)
* 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);
Expand All @@ -83,7 +83,25 @@ public function searchEntitiesAjax(Request $request)
$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)
]);
}

/**
Expand Down
78 changes: 21 additions & 57 deletions resources/js/components/dropdown.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {onSelect} from "../services/dom";
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";

/**
* Dropdown
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
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'));
Expand All @@ -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();
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/entity-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/global-search.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions resources/js/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion resources/js/components/page-editor.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Dates from "../services/dates";
import {onSelect} from "../services/dom";
import {debounce} from "../services/util";

/**
* Page Editor
Expand Down Expand Up @@ -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));
Expand Down
89 changes: 89 additions & 0 deletions resources/js/services/keyboard-navigation.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 3 additions & 3 deletions resources/js/services/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading