diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js new file mode 100644 index 000000000..7fce09890 --- /dev/null +++ b/resources/js/components/auto-suggest.js @@ -0,0 +1,144 @@ +import {escapeHtml} from "../services/util"; +import {onChildEvent} from "../services/dom"; + +const ajaxCache = {}; + +/** + * AutoSuggest + * @extends {Component} + */ +class AutoSuggest { + setup() { + this.parent = this.$el.parentElement; + this.container = this.$el; + this.type = this.$opts.type; + this.url = this.$opts.url; + this.input = this.$refs.input; + this.list = this.$refs.list; + + this.setupListeners(); + } + + setupListeners() { + this.input.addEventListener('input', this.requestSuggestions.bind(this)); + this.input.addEventListener('focus', this.requestSuggestions.bind(this)); + this.input.addEventListener('keydown', event => { + if (event.key === 'Tab') { + this.hideSuggestions(); + } + }); + + this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this)); + this.container.addEventListener('keydown', this.containerKeyDown.bind(this)); + + onChildEvent(this.list, 'button', 'click', (event, el) => { + this.selectSuggestion(el.textContent); + }); + onChildEvent(this.list, 'button', 'keydown', (event, el) => { + if (event.key === 'Enter') { + this.selectSuggestion(el.textContent); + } + }); + + } + + selectSuggestion(value) { + this.input.value = value; + this.input.focus(); + this.hideSuggestions(); + } + + containerKeyDown(event) { + if (event.key === 'Enter') event.preventDefault(); + if (this.list.classList.contains('hidden')) return; + + // Down arrow + if (event.key === 'ArrowDown') { + this.moveFocus(true); + event.preventDefault(); + } + // Up Arrow + else if (event.key === 'ArrowUp') { + this.moveFocus(false); + event.preventDefault(); + } + // Escape key + else if (event.key === 'Escape') { + this.hideSuggestions(); + event.preventDefault(); + } + } + + moveFocus(forward = true) { + const focusables = Array.from(this.container.querySelectorAll('input,button')); + const index = focusables.indexOf(document.activeElement); + const newFocus = focusables[index + (forward ? 1 : -1)]; + if (newFocus) { + newFocus.focus() + } + } + + async requestSuggestions() { + const nameFilter = this.getNameFilterIfNeeded(); + const search = this.input.value.slice(0, 3); + const suggestions = await this.loadSuggestions(search, nameFilter); + let toShow = suggestions.slice(0, 6); + if (search.length > 0) { + toShow = suggestions.filter(val => { + return val.toLowerCase().includes(search); + }).slice(0, 6); + } + + this.displaySuggestions(toShow); + } + + getNameFilterIfNeeded() { + if (this.type !== 'value') return null; + return this.parent.querySelector('input').value; + } + + /** + * @param {String} search + * @param {String|null} nameFilter + * @returns {Promise} + */ + async loadSuggestions(search, nameFilter = null) { + const params = {search, name: nameFilter}; + const cacheKey = `${this.url}:${JSON.stringify(params)}`; + + if (ajaxCache[cacheKey]) { + return ajaxCache[cacheKey]; + } + + const resp = await window.$http.get(this.url, params); + ajaxCache[cacheKey] = resp.data; + return resp.data; + } + + /** + * @param {String[]} suggestions + */ + displaySuggestions(suggestions) { + if (suggestions.length === 0) { + return this.hideSuggestions(); + } + + this.list.innerHTML = suggestions.map(value => `
  • `).join(''); + this.list.style.display = 'block'; + for (const button of this.list.querySelectorAll('button')) { + button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this)); + } + } + + hideSuggestions() { + this.list.style.display = 'none'; + } + + hideSuggestionsIfFocusedLost(event) { + if (!this.container.contains(event.relatedTarget)) { + this.hideSuggestions(); + } + } +} + +export default AutoSuggest; \ No newline at end of file diff --git a/resources/js/services/util.js b/resources/js/services/util.js index b2f291872..b44b7de6c 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -45,4 +45,19 @@ export function scrollAndHighlightElement(element) { element.classList.remove('selectFade'); element.style.backgroundColor = ''; }, 3000); +} + +/** + * Escape any HTML in the given 'unsafe' string. + * Take from https://stackoverflow.com/a/6234804. + * @param {String} unsafe + * @returns {string} + */ +export function escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } \ No newline at end of file diff --git a/resources/js/vues/components/autosuggest.js b/resources/js/vues/components/autosuggest.js index 9832a9eb4..f4bb3d815 100644 --- a/resources/js/vues/components/autosuggest.js +++ b/resources/js/vues/components/autosuggest.js @@ -2,12 +2,15 @@ const template = `
    + @include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book']) @stop diff --git a/resources/views/components/tag-manager-list.blade.php b/resources/views/components/tag-manager-list.blade.php new file mode 100644 index 000000000..99ee87782 --- /dev/null +++ b/resources/views/components/tag-manager-list.blade.php @@ -0,0 +1,25 @@ +@foreach(array_merge($tags, [new \BookStack\Actions\Tag]) as $index => $tag) +
    +
    @icon('grip')
    + @foreach(['name', 'value'] as $type) +
    + + +
    + @endforeach + +
    +@endforeach \ No newline at end of file diff --git a/resources/views/components/tag-manager.blade.php b/resources/views/components/tag-manager.blade.php index 287856937..0fab30d63 100644 --- a/resources/views/components/tag-manager.blade.php +++ b/resources/views/components/tag-manager.blade.php @@ -1,7 +1,9 @@ -
    +

    {!! nl2br(e(trans('entities.tags_explain'))) !!}

    + @include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []]) +
    @icon('grip')