mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-28 07:20:49 -04:00

the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
125 lines
3.3 KiB
Vue
125 lines
3.3 KiB
Vue
<script setup lang="ts">
|
|
import { onKeyStroke, useDebounce } from '@vueuse/core';
|
|
|
|
import { normaliseQuery, validateQuery } from '#shared/search.ts';
|
|
|
|
const emit = defineEmits<{
|
|
selected: [];
|
|
}>();
|
|
|
|
const query = ref('');
|
|
const normalisedQuery = computed(() => {
|
|
return normaliseQuery(query.value);
|
|
});
|
|
const debouncedQuery = useDebounce(normalisedQuery);
|
|
|
|
const queryValidation = computed(() => {
|
|
return validateQuery(normalisedQuery.value);
|
|
});
|
|
|
|
const searchAsyncData = useAsyncData('search', async () => {
|
|
selected.value = null;
|
|
if (queryValidation.value !== undefined) {
|
|
return [];
|
|
}
|
|
return await $fetch('/api/search', {
|
|
query: {
|
|
query: debouncedQuery.value,
|
|
},
|
|
});
|
|
}, {
|
|
watch: [debouncedQuery],
|
|
});
|
|
|
|
const isLoading = computed(() => {
|
|
return queryValidation.value === undefined &&
|
|
(normalisedQuery.value !== debouncedQuery.value || searchAsyncData.status.value === 'pending');
|
|
});
|
|
|
|
const hasNoResults = computed(() => {
|
|
return !isLoading.value && searchAsyncData.data.value?.length === 0;
|
|
});
|
|
|
|
onKeyStroke('k', (event) => {
|
|
const isCtrlKeyPressed = isMac.value ? event.metaKey : event.ctrlKey;
|
|
if (isCtrlKeyPressed) {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
|
|
const selected = ref<number | null>(null);
|
|
const searchItems = useTemplateRef('searchItems');
|
|
onKeyStroke('ArrowDown', () => {
|
|
selected.value = Math.min(
|
|
(selected.value ?? -1) + 1,
|
|
(searchAsyncData.data?.value?.length ?? 0) - 1,
|
|
);
|
|
});
|
|
onKeyStroke('ArrowUp', () => {
|
|
selected.value = Math.max(
|
|
(selected.value ?? -1) - 1,
|
|
0,
|
|
);
|
|
});
|
|
onKeyStroke('Enter', () => {
|
|
if (selected.value === null) {
|
|
return;
|
|
}
|
|
|
|
const document = searchAsyncData.data.value?.[selected.value];
|
|
if (!document) {
|
|
return;
|
|
}
|
|
|
|
const link = searchItems.value?.[selected.value]?.firstElementChild as HTMLElement | undefined;
|
|
link?.click();
|
|
});
|
|
|
|
const reset = () => {
|
|
query.value = '';
|
|
searchAsyncData.clear();
|
|
selected.value = null;
|
|
};
|
|
|
|
defineExpose({ reset });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="input-group mb-4">
|
|
<span class="input-group-text">
|
|
<Spinner v-if="isLoading" size="1.25em" />
|
|
<Icon v-else v="search" />
|
|
</span>
|
|
<input
|
|
v-model="query"
|
|
type="search"
|
|
autofocus
|
|
class="form-control border-primary"
|
|
:placeholder="$t('crud.search')"
|
|
>
|
|
<button type="button" class="btn btn-outline-danger" @click="emit('selected')">
|
|
<Icon v="times" />
|
|
</button>
|
|
</div>
|
|
<div v-if="queryValidation !== undefined" class="alert alert-info">
|
|
<T>search.{{ queryValidation }}</T>
|
|
</div>
|
|
<ul
|
|
v-else-if="searchAsyncData.data.value?.length ?? 0 > 0"
|
|
class="list-group"
|
|
>
|
|
<li
|
|
v-for="(document, index) of searchAsyncData.data.value"
|
|
:key="`${document.kind}-${document.id}`"
|
|
ref="searchItems"
|
|
:class="['list-group-item list-group-item-action py-0 pe-0', selected === index ? 'list-group-item-active' : '']"
|
|
>
|
|
<SearchItem :document="document" @click="emit('selected')" />
|
|
</li>
|
|
</ul>
|
|
<div v-else-if="hasNoResults" class="alert alert-info">
|
|
<T :params="{ query }">search.noResults</T>
|
|
</div>
|
|
</template>
|