]> BookStack Code Mirror - bookstack/commitdiff
Started migrating tag manager JS to HTML-first component
authorDan Brown <redacted>
Sun, 28 Jun 2020 22:15:05 +0000 (23:15 +0100)
committerDan Brown <redacted>
Sun, 28 Jun 2020 22:15:05 +0000 (23:15 +0100)
resources/js/components/auto-suggest.js [new file with mode: 0644]
resources/js/services/util.js
resources/js/vues/components/autosuggest.js
resources/sass/_blocks.scss
resources/sass/_pages.scss
resources/views/common/home.blade.php
resources/views/components/tag-manager-list.blade.php [new file with mode: 0644]
resources/views/components/tag-manager.blade.php

diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js
new file mode 100644 (file)
index 0000000..7fce098
--- /dev/null
@@ -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;
\ No newline at end of file
index b2f2918725004920543a439d6031e3cce57aecc1..b44b7de6c2f62365e20fbddba8fda6b51115c0b8 100644 (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;");
 }
\ No newline at end of file
index 9832a9eb407b3a3982d42d0e1bb7619e122b2708..f4bb3d8152086d972f723d0606e55b3b004b2098 100644 (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"
index b6f35376d20e57c5f7956af4ae39c51f5311b6d4..697286a78cf5f606ed06a695c4ebb73c9cd71bfd 100644 (file)
       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;
index 1ed02d2e7bec40c957a816134b0812aa1aa50666..4f249244b03f88a388c3b6c97855b01d27ac1d0a 100755 (executable)
@@ -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;
-    }
   }
 }
 
index 63b76aa108b4c73d82bbc8899305df924801540a..7df6d4ce6f0aad67dafca4e92d00922c852cfc1e 100644 (file)
@@ -66,6 +66,7 @@
         </div>
     </div>
 
+    @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 (file)
index 0000000..99ee877
--- /dev/null
@@ -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
\ No newline at end of file
index 2878569374d6db6bd80928a3fe34d8477d982124..0fab30d63f05cc87959e95cb28f151a10f6170a9 100644 (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>