<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>
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) {
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>