Implement tag menu in search input
authorAdam Dullage <redacted>
Sun, 12 May 2024 09:47:15 +0000 (10:47 +0100)
committerAdam Dullage <redacted>
Sun, 12 May 2024 09:47:15 +0000 (10:47 +0100)
client/api.js
client/partials/SearchInput.vue
client/partials/SearchModal.vue

index 05fcee6c331af4ec62ea0b6b2cca6aab725c141d..df2a624fe987c606e64300e3a78245db54a1213a 100644 (file)
@@ -118,3 +118,12 @@ export async function deleteNote(title) {
     return Promise.reject(response);
   }
 }
+
+export async function getTags() {
+  try {
+    const response = await api.get("/api/tags");
+    return response.data;
+  } catch (response) {
+    return Promise.reject(response);
+  }
+}
index 380749f0a3ba9705f7ed56ee190b6ccaa34d5912..bcebcdd118243897c7b55b2a01d4c5ec84700754 100644 (file)
@@ -1,17 +1,46 @@
 <template>
-  <form
-    class="flex w-full rounded-md border border-theme-border bg-theme-background px-3 py-2 dark:bg-theme-background-elevated"
-    @submit.prevent="search"
-  >
-    <IconLabel :iconPath="mdilMagnify" class="mr-2" />
-    <input
-      type="text"
-      v-model="searchTerm"
-      v-focus
-      class="w-full bg-transparent focus:outline-none"
-      placeholder="Search..."
-    />
-  </form>
+  <div class="relative w-full">
+    <!-- Input -->
+    <div
+      class="flex w-full rounded-md border border-theme-border bg-theme-background dark:bg-theme-background-elevated"
+      :class="{ 'px-3 py-2': !large, 'px-5 py-4': large }"
+    >
+      <IconLabel :iconPath="mdilMagnify" class="mr-2" />
+      <input
+        type="text"
+        ref="input"
+        v-model="searchTerm"
+        v-focus
+        class="w-full bg-transparent focus:outline-none"
+        :placeholder="placeholder"
+        @keyup="inputEventHandler"
+        @click="inputEventHandler"
+        @blur="tagMenuVisible = false"
+        @keydown.down.prevent
+        @keydown.up.prevent
+      />
+      <!-- Note: Default behaviour for up and down keys is prevented to stop cursor moving when tag menu is navigated. -->
+    </div>
+
+    <!-- Tag Menu -->
+    <div
+      v-if="tagMenuVisible"
+      class="absolute z-10 mt-1 max-h-64 w-full overflow-scroll rounded-md border border-theme-border bg-theme-background p-1 dark:bg-theme-background-elevated"
+    >
+      <p
+        v-for="(tag, index) in tagMatches"
+        ref="tagMenuItems"
+        class="cursor-pointer rounded px-2 py-1"
+        :class="{ 'bg-theme-background-elevated': index === tagMenuIndex }"
+        @mouseover="tagMenuIndex = index"
+        @click="tagChosen(tag)"
+        @mousedown.prevent
+      >
+        <!-- Note: Default behaviour for mouse down is prevented to stop focus moving to menu on click. -->
+        {{ tag }}
+      </p>
+    </div>
+  </div>
 </template>
 
 <script setup>
@@ -21,17 +50,65 @@ import { ref } from "vue";
 import { useRouter } from "vue-router";
 import * as constants from "../constants";
 
+import { getTags, apiErrorHandler } from "../api.js";
 import IconLabel from "../components/IconLabel.vue";
 import { getToastOptions } from "../helpers.js";
 
 const props = defineProps({
-  initialSearchTerm: String,
+  initialSearchTerm: { type: String, default: "" },
+  large: Boolean,
+  placeholder: { type: String, default: "Search..." },
 });
 const emit = defineEmits(["search"]);
 
+const input = ref();
 const router = useRouter();
 const searchTerm = ref(props.initialSearchTerm);
 const toast = useToast();
+let tags = null;
+const tagMatches = ref([]);
+const tagMenuItems = ref([]);
+const tagMenuIndex = ref(0);
+const tagMenuVisible = ref(false);
+
+function inputEventHandler(event) {
+  // Tag Menu Open
+  if (tagMenuVisible.value) {
+    if (event.key === "ArrowDown") {
+      tagMenuIndex.value = Math.min(
+        tagMenuIndex.value + 1,
+        tagMatches.value.length - 1,
+      );
+      tagMenuItems.value[tagMenuIndex.value].scrollIntoView({
+        block: "nearest",
+      });
+    } else if (event.key === "ArrowUp") {
+      tagMenuIndex.value = Math.max(tagMenuIndex.value - 1, 0);
+      tagMenuItems.value[tagMenuIndex.value].scrollIntoView({
+        block: "nearest",
+      });
+    } else if (event.key === "Enter") {
+      tagChosen(tagMatches.value[tagMenuIndex.value]);
+    } else if (event.key === "Escape") {
+      tagMenuVisible.value = false;
+    } else {
+      stateChangeHandler();
+    }
+  }
+  // Tag Menu Closed
+  else {
+    if (event.key === "Enter") {
+      search();
+    } else {
+      stateChangeHandler();
+    }
+  }
+}
+
+function tagChosen(tag) {
+  replaceWordOnCursor(tag);
+  tagMenuVisible.value = false;
+}
 
 function search() {
   if (searchTerm.value) {
@@ -44,4 +121,71 @@ function search() {
     toast.add(getToastOptions("Error", "Please enter a search term.", true));
   }
 }
+
+function stateChangeHandler() {
+  const wordOnCursor = getWordOnCursor();
+  if (wordOnCursor.charAt(0) !== "#") {
+    tagMenuVisible.value = false;
+  } else {
+    filterTagMatches(wordOnCursor);
+  }
+}
+
+async function filterTagMatches(input) {
+  if (tags === null) {
+    try {
+      tags = await getTags();
+    } catch (error) {
+      tags = [];
+      apiErrorHandler(error, toast);
+    }
+    tags = tags.map((tag) => `#${tag}`);
+  }
+  tagMatches.value = tags.filter(
+    (tag) => tag.startsWith(input) && tag !== input,
+  );
+  tagMenuIndex.value = 0;
+  tagMenuVisible.value = tagMatches.value.length > 0;
+}
+
+// Helpers
+
+/**
+ * Returns the word that the cursor is currently on.
+ * @returns {Object} An object containing the start and end indices of the word.
+ */
+function getWordOnCursorPosition() {
+  const cursorPosition = input.value.selectionStart;
+  const wordStart = Math.max(
+    searchTerm.value.lastIndexOf(" ", cursorPosition - 1) + 1,
+    0,
+  );
+  let wordEnd = searchTerm.value.indexOf(" ", cursorPosition);
+  if (wordEnd === -1) {
+    // If there is no space after the cursor, then the word ends at the end of the input.
+    wordEnd = searchTerm.value.length;
+  }
+  return { start: wordStart, end: wordEnd };
+}
+
+/**
+ * Retrieves the word at the current cursor position in the search term.
+ * @returns {string} The word at the cursor position.
+ */
+function getWordOnCursor() {
+  const { start, end } = getWordOnCursorPosition();
+  return searchTerm.value.substring(start, end);
+}
+
+/**
+ * Replaces the word at the cursor position with the given replacement.
+ * @param {string} replacement The word to replace the current word with.
+ */
+function replaceWordOnCursor(replacement) {
+  const { start, end } = getWordOnCursorPosition();
+  searchTerm.value =
+    searchTerm.value.substring(0, start) +
+    replacement +
+    searchTerm.value.substring(end);
+}
 </script>
index c6b7d7585891fd5b8e6dae9cf225699ecbebe8a2..7cbdd848b0957dcf330c0eb414f66f8a85d6cb16 100644 (file)
@@ -1,7 +1,8 @@
 <template>
   <Modal v-model="isVisible" class="border-none">
     <SearchInput
-      class="px-5 py-4"
+      large
+      placeholder="Search by title, content or #tags..."
       @search="toggleHandler"
       @keyup.esc="toggleHandler"
     />
git clone https://git.99rst.org/PROJECT