Merge branch 'search_preview' into development · phsteffen/BookStack@ffc9c28 · GitHub
Skip to content

Commit ffc9c28

Browse files
committed
Merge branch 'search_preview' into development
2 parents bbf13e9 + fcff206 commit ffc9c28

17 files changed

Lines changed: 367 additions & 110 deletions

app/Http/Controllers/SearchController.php

Lines changed: 21 additions & 3 deletions

resources/js/components/dropdown.js

Lines changed: 22 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {onSelect} from "../services/dom";
2+
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
23
import {Component} from "./component";
34

45
/**
@@ -17,8 +18,9 @@ export class Dropdown extends Component {
1718
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
1819
this.body = document.body;
1920
this.showing = false;
20-
this.setupListeners();
21+
2122
this.hide = this.hide.bind(this);
23+
this.setupListeners();
2224
}
2325

2426
show(event = null) {
@@ -52,7 +54,7 @@ export class Dropdown extends Component {
5254
}
5355

5456
// Set listener to hide on mouse leave or window click
55-
this.menu.addEventListener('mouseleave', this.hide.bind(this));
57+
this.menu.addEventListener('mouseleave', this.hide);
5658
window.addEventListener('click', event => {
5759
if (!this.menu.contains(event.target)) {
5860
this.hide();
@@ -97,33 +99,25 @@ export class Dropdown extends Component {
9799
this.showing = false;
98100
}
99101

100-
getFocusable() {
101-
return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
102-
}
103-
104-
focusNext() {
105-
const focusable = this.getFocusable();
106-
const currentIndex = focusable.indexOf(document.activeElement);
107-
let newIndex = currentIndex + 1;
108-
if (newIndex >= focusable.length) {
109-
newIndex = 0;
110-
}
111-
112-
focusable[newIndex].focus();
113-
}
102+
setupListeners() {
103+
const keyboardNavHandler = new KeyboardNavigationHandler(this.container, (event) => {
104+
this.hide();
105+
this.toggle.focus();
106+
if (!this.bubbleEscapes) {
107+
event.stopPropagation();
108+
}
109+
}, (event) => {
110+
if (event.target.nodeName === 'INPUT') {
111+
event.preventDefault();
112+
event.stopPropagation();
113+
}
114+
this.hide();
115+
});
114116

115-
focusPrevious() {
116-
const focusable = this.getFocusable();
117-
const currentIndex = focusable.indexOf(document.activeElement);
118-
let newIndex = currentIndex - 1;
119-
if (newIndex < 0) {
120-
newIndex = focusable.length - 1;
117+
if (this.moveMenu) {
118+
keyboardNavHandler.shareHandlingToEl(this.menu);
121119
}
122120

123-
focusable[newIndex].focus();
124-
}
125-
126-
setupListeners() {
127121
// Hide menu on option click
128122
this.container.addEventListener('click', event => {
129123
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
@@ -136,39 +130,9 @@ export class Dropdown extends Component {
136130
event.stopPropagation();
137131
this.show(event);
138132
if (event instanceof KeyboardEvent) {
139-
this.focusNext();
140-
}
141-
});
142-
143-
// Keyboard navigation
144-
const keyboardNavigation = event => {
145-
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
146-
this.focusNext();
147-
event.preventDefault();
148-
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
149-
this.focusPrevious();
150-
event.preventDefault();
151-
} else if (event.key === 'Escape') {
152-
this.hide();
153-
this.toggle.focus();
154-
if (!this.bubbleEscapes) {
155-
event.stopPropagation();
156-
}
157-
}
158-
};
159-
this.container.addEventListener('keydown', keyboardNavigation);
160-
if (this.moveMenu) {
161-
this.menu.addEventListener('keydown', keyboardNavigation);
162-
}
163-
164-
// Hide menu on enter press or escape
165-
this.menu.addEventListener('keydown ', event => {
166-
if (event.key === 'Enter') {
167-
event.preventDefault();
168-
event.stopPropagation();
169-
this.hide();
133+
keyboardNavHandler.focusNext();
170134
}
171135
});
172136
}
173137

174-
}
138+
}

resources/js/components/entity-selector.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export class EntitySelector extends Component {
115115
}
116116

117117
searchUrl() {
118-
return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
118+
return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
119119
}
120120

121121
searchEntities(searchTerm) {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {htmlToDom} from "../services/dom";
2+
import {debounce} from "../services/util";
3+
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
4+
5+
/**
6+
* @extends {Component}
7+
*/
8+
class GlobalSearch {
9+
10+
setup() {
11+
this.container = this.$el;
12+
this.input = this.$refs.input;
13+
this.suggestions = this.$refs.suggestions;
14+
this.suggestionResultsWrap = this.$refs.suggestionResults;
15+
this.loadingWrap = this.$refs.loading;
16+
this.button = this.$refs.button;
17+
18+
this.setupListeners();
19+
}
20+
21+
setupListeners() {
22+
const updateSuggestionsDebounced = debounce(this.updateSuggestions.bind(this), 200, false);
23+
24+
// Handle search input changes
25+
this.input.addEventListener('input', () => {
26+
const value = this.input.value;
27+
if (value.length > 0) {
28+
this.loadingWrap.style.display = 'block';
29+
this.suggestionResultsWrap.style.opacity = '0.5';
30+
updateSuggestionsDebounced(value);
31+
} else {
32+
this.hideSuggestions();
33+
}
34+
});
35+
36+
// Allow double click to show auto-click suggestions
37+
this.input.addEventListener('dblclick', () => {
38+
this.input.setAttribute('autocomplete', 'on');
39+
this.button.focus();
40+
this.input.focus();
41+
});
42+
43+
new KeyboardNavigationHandler(this.container, () => {
44+
this.hideSuggestions();
45+
});
46+
}
47+
48+
/**
49+
* @param {String} search
50+
*/
51+
async updateSuggestions(search) {
52+
const {data: results} = await window.$http.get('/search/suggest', {term: search});
53+
if (!this.input.value) {
54+
return;
55+
}
56+
57+
const resultDom = htmlToDom(results);
58+
59+
this.suggestionResultsWrap.innerHTML = '';
60+
this.suggestionResultsWrap.style.opacity = '1';
61+
this.loadingWrap.style.display = 'none';
62+
this.suggestionResultsWrap.append(resultDom);
63+
if (!this.container.classList.contains('search-active')) {
64+
this.showSuggestions();
65+
}
66+
}
67+
68+
showSuggestions() {
69+
this.container.classList.add('search-active');
70+
window.requestAnimationFrame(() => {
71+
this.suggestions.classList.add('search-suggestions-animation');
72+
})
73+
}
74+
75+
hideSuggestions() {
76+
this.container.classList.remove('search-active');
77+
this.suggestions.classList.remove('search-suggestions-animation');
78+
this.suggestionResultsWrap.innerHTML = '';
79+
}
80+
}
81+
82+
export default GlobalSearch;

resources/js/components/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ export {ToggleSwitch} from "./toggle-switch.js"
5555
export {TriLayout} from "./tri-layout.js"
5656
export {UserSelect} from "./user-select.js"
5757
export {WebhookEvents} from "./webhook-events";
58-
export {WysiwygEditor} from "./wysiwyg-editor.js"
58+
export {WysiwygEditor} from "./wysiwyg-editor.js"

resources/js/components/page-editor.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Dates from "../services/dates";
22
import {onSelect} from "../services/dom";
3+
import {debounce} from "../services/util";
34
import {Component} from "./component";
45

56
export class PageEditor extends Component {
@@ -66,7 +67,8 @@ export class PageEditor extends Component {
6667
});
6768

6869
// Changelog controls
69-
this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
70+
const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
71+
this.changelogInput.addEventListener('input', updateChangelogDebounced);
7072

7173
// Draft Controls
7274
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
@@ -205,4 +207,4 @@ export class PageEditor extends Component {
205207
}
206208
}
207209

208-
}
210+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Handle common keyboard navigation events within a given container.
3+
*/
4+
export class KeyboardNavigationHandler {
5+
6+
/**
7+
* @param {Element} container
8+
* @param {Function|null} onEscape
9+
* @param {Function|null} onEnter
10+
*/
11+
constructor(container, onEscape = null, onEnter = null) {
12+
this.containers = [container];
13+
this.onEscape = onEscape;
14+
this.onEnter = onEnter;
15+
container.addEventListener('keydown', this.#keydownHandler.bind(this));
16+
}
17+
18+
/**
19+
* Also share the keyboard event handling to the given element.
20+
* Only elements within the original container are considered focusable though.
21+
* @param {Element} element
22+
*/
23+
shareHandlingToEl(element) {
24+
this.containers.push(element);
25+
element.addEventListener('keydown', this.#keydownHandler.bind(this));
26+
}
27+
28+
/**
29+
* Focus on the next focusable element within the current containers.
30+
*/
31+
focusNext() {
32+
const focusable = this.#getFocusable();
33+
const currentIndex = focusable.indexOf(document.activeElement);
34+
let newIndex = currentIndex + 1;
35+
if (newIndex >= focusable.length) {
36+
newIndex = 0;
37+
}
38+
39+
focusable[newIndex].focus();
40+
}
41+
42+
/**
43+
* Focus on the previous existing focusable element within the current containers.
44+
*/
45+
focusPrevious() {
46+
const focusable = this.#getFocusable();
47+
const currentIndex = focusable.indexOf(document.activeElement);
48+
let newIndex = currentIndex - 1;
49+
if (newIndex < 0) {
50+
newIndex = focusable.length - 1;
51+
}
52+
53+
focusable[newIndex].focus();
54+
}
55+
56+
/**
57+
* @param {KeyboardEvent} event
58+
*/
59+
#keydownHandler(event) {
60+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
61+
this.focusNext();
62+
event.preventDefault();
63+
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
64+
this.focusPrevious();
65+
event.preventDefault();
66+
} else if (event.key === 'Escape') {
67+
if (this.onEscape) {
68+
this.onEscape(event);
69+
} else if (document.activeElement) {
70+
document.activeElement.blur();
71+
}
72+
} else if (event.key === 'Enter' && this.onEnter) {
73+
this.onEnter(event);
74+
}
75+
}
76+
77+
/**
78+
* Get an array of focusable elements within the current containers.
79+
* @returns {Element[]}
80+
*/
81+
#getFocusable() {
82+
const focusable = [];
83+
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
84+
for (const container of this.containers) {
85+
focusable.push(...container.querySelectorAll(selector))
86+
}
87+
return focusable;
88+
}
89+
}

resources/js/services/util.js

Lines changed: 3 additions & 3 deletions

0 commit comments

Comments
 (0)