Started migrating tag manager JS to HTML-first component

This commit is contained in:
Dan Brown 2020-06-28 23:15:05 +01:00
parent 10305a4446
commit 4e107b9160
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 201 additions and 16 deletions

View 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;

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@ -2,12 +2,15 @@
const template = `
<div>
<input :value="value" :autosuggest-type="type" ref="input"
:placeholder="placeholder" :name="name"
:placeholder="placeholder"
:name="name"
type="text"
@input="inputUpdate($event.target.value)" @focus="inputUpdate($event.target.value)"
@input="inputUpdate($event.target.value)"
@focus="inputUpdate($event.target.value)"
@blur="inputBlur"
@keydown="inputKeydown"
:aria-label="placeholder"
autocomplete="off"
/>
<ul class="suggestion-box" v-if="showSuggestions">
<li v-for="(suggestion, i) in suggestions"

View File

@ -115,10 +115,13 @@
margin-inline-end: 0px;
}
}
> div .outline input {
.outline input {
margin: $-s 0;
width: 100%;
}
.outline {
position: relative;
}
.handle {
@include lightDark(background-color, #eee, #2d2d2d);
left: 0;

View File

@ -327,25 +327,17 @@ body.mce-fullscreen, body.markdown-fullscreen {
}
.suggestion-box {
position: absolute;
background-color: #FFF;
border: 1px solid #BBB;
box-shadow: $bs-light;
list-style: none;
z-index: 100;
top: auto;
margin: -4px 0 0;
right: auto;
left: 0;
padding: 0;
margin: 0;
border-radius: 3px;
li {
display: block;
padding: $-xs $-s;
border-bottom: 1px solid #DDD;
&:last-child {
border-bottom: 0;
}
&.active {
background-color: #EEE;
}
}
}

View File

@ -66,6 +66,7 @@
</div>
</div>
@include('components.tag-manager', ['entity' => \BookStack\Entities\Book::find(1), 'entityType' => 'book'])
@stop

View 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

View File

@ -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">
<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">
<div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
<div class="handle" >@icon('grip')</div>