mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-08-25 19:56:05 +00:00
Started migrating tag manager JS to HTML-first component
This commit is contained in:
parent
10305a4446
commit
4e107b9160
144
resources/js/components/auto-suggest.js
Normal file
144
resources/js/components/auto-suggest.js
Normal file
@ -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<Object|String|*>}
|
||||||
|
*/
|
||||||
|
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 => `<li><button type="button">${escapeHtml(value)}</button></li>`).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;
|
@ -46,3 +46,18 @@ export function scrollAndHighlightElement(element) {
|
|||||||
element.style.backgroundColor = '';
|
element.style.backgroundColor = '';
|
||||||
}, 3000);
|
}, 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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
@ -2,12 +2,15 @@
|
|||||||
const template = `
|
const template = `
|
||||||
<div>
|
<div>
|
||||||
<input :value="value" :autosuggest-type="type" ref="input"
|
<input :value="value" :autosuggest-type="type" ref="input"
|
||||||
:placeholder="placeholder" :name="name"
|
:placeholder="placeholder"
|
||||||
|
:name="name"
|
||||||
type="text"
|
type="text"
|
||||||
@input="inputUpdate($event.target.value)" @focus="inputUpdate($event.target.value)"
|
@input="inputUpdate($event.target.value)"
|
||||||
|
@focus="inputUpdate($event.target.value)"
|
||||||
@blur="inputBlur"
|
@blur="inputBlur"
|
||||||
@keydown="inputKeydown"
|
@keydown="inputKeydown"
|
||||||
:aria-label="placeholder"
|
:aria-label="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<ul class="suggestion-box" v-if="showSuggestions">
|
<ul class="suggestion-box" v-if="showSuggestions">
|
||||||
<li v-for="(suggestion, i) in suggestions"
|
<li v-for="(suggestion, i) in suggestions"
|
||||||
|
@ -115,10 +115,13 @@
|
|||||||
margin-inline-end: 0px;
|
margin-inline-end: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
> div .outline input {
|
.outline input {
|
||||||
margin: $-s 0;
|
margin: $-s 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.outline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.handle {
|
.handle {
|
||||||
@include lightDark(background-color, #eee, #2d2d2d);
|
@include lightDark(background-color, #eee, #2d2d2d);
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -327,25 +327,17 @@ body.mce-fullscreen, body.markdown-fullscreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-box {
|
.suggestion-box {
|
||||||
position: absolute;
|
top: auto;
|
||||||
background-color: #FFF;
|
margin: -4px 0 0;
|
||||||
border: 1px solid #BBB;
|
right: auto;
|
||||||
box-shadow: $bs-light;
|
left: 0;
|
||||||
list-style: none;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
li {
|
li {
|
||||||
display: block;
|
display: block;
|
||||||
padding: $-xs $-s;
|
|
||||||
border-bottom: 1px solid #DDD;
|
border-bottom: 1px solid #DDD;
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
&.active {
|
|
||||||
background-color: #EEE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,6 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book'])
|
||||||
|
|
||||||
|
|
||||||
@stop
|
@stop
|
||||||
|
25
resources/views/components/tag-manager-list.blade.php
Normal file
25
resources/views/components/tag-manager-list.blade.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
@foreach(array_merge($tags, [new \BookStack\Actions\Tag]) as $index => $tag)
|
||||||
|
<div class="card drag-card">
|
||||||
|
<div class="handle">@icon('grip')</div>
|
||||||
|
@foreach(['name', 'value'] as $type)
|
||||||
|
<div component="auto-suggest"
|
||||||
|
option:auto-suggest:url="{{ url('/ajax/tags/suggest/' . $type . 's') }}"
|
||||||
|
option:auto-suggest:type="{{ $type }}"
|
||||||
|
class="outline">
|
||||||
|
<input value="{{ $tag->$type ?? '' }}"
|
||||||
|
placeholder="{{ trans('entities.tag_' . $type) }}"
|
||||||
|
aria-label="{{ trans('entities.tag_' . $type) }}"
|
||||||
|
name="tags[{{ $index }}][{{ $type }}]"
|
||||||
|
type="text"
|
||||||
|
refs="auto-suggest@input"
|
||||||
|
autocomplete="off"/>
|
||||||
|
<ul refs="auto-suggest@list" class="suggestion-box dropdown-menu"></ul>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
<button refs="tag-manager@remove" type="button"
|
||||||
|
aria-label="{{ trans('entities.tags_remove') }}"
|
||||||
|
class="text-center drag-card-action text-neg {{ count($tags) > 0 ? '' : 'hidden' }}">
|
||||||
|
@icon('close')
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
@ -1,7 +1,9 @@
|
|||||||
<div id="tag-manager" entity-id="{{ isset($entity) ? $entity->id : 0 }}" entity-type="{{ $entity ? $entity->getType() : $entityType }}">
|
<div id="tag-manager" entity-id="{{ $entity->id ?? 0 }}" entity-type="{{ $entity ? $entity->getType() : $entityType }}">
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
|
<p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
|
||||||
|
|
||||||
|
@include('components.tag-manager-list', ['tags' => $entity->tags->all() ?? []])
|
||||||
|
|
||||||
<draggable :options="{handle: '.handle'}" :list="tags" element="div">
|
<draggable :options="{handle: '.handle'}" :list="tags" element="div">
|
||||||
<div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
|
<div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
|
||||||
<div class="handle" >@icon('grip')</div>
|
<div class="handle" >@icon('grip')</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user