PronounsPage/components/search/SearchDialogue.vue
2024-12-29 15:30:23 +01:00

148 lines
4.0 KiB
Vue

<script setup lang="ts">
import { onKeyStroke, useDebounce } from '@vueuse/core';
import { normaliseQuery, validateQuery } from '~/src/search.ts';
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;
});
const dialogue = useTemplateRef('dialogue');
const open = () => {
query.value = '';
searchAsyncData.clear();
dialogue.value?.showModal();
selected.value = null;
};
const close = () => {
dialogue.value?.close();
};
onKeyStroke('k', (event) => {
const isCtrlKeyPressed = isMac.value ? event.metaKey : event.ctrlKey;
if (isCtrlKeyPressed) {
event.preventDefault();
open();
}
});
const onMousedown = (event: MouseEvent) => {
const rect = dialogue.value?.getBoundingClientRect();
if (rect && (
event.clientX < rect.left || event.clientX > rect.right ||
event.clientY < rect.top || event.clientY > rect.bottom)
) {
close();
}
};
const selected = ref<number | null>(null);
const router = useRouter();
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;
}
router.push(document.url);
close();
});
defineExpose({ open, close });
</script>
<template>
<dialog ref="dialogue" class="container m-auto h-100 rounded border" @mousedown="onMousedown">
<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"
class="form-control border-primary"
:placeholder="$t('crud.search')"
>
<button class="btn btn-outline-danger" @click="close()">
<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}`"
:class="['list-group-item list-group-item-action p-0', selected === index ? 'list-group-item-active' : '']"
>
<SearchItem :document="document" @click="close()" />
</li>
</ul>
<div v-else-if="hasNoResults" class="alert alert-info">
<T :params="{ query }">search.noResults</T>
</div>
</dialog>
</template>
<style lang="scss">
@import "assets/variables";
.list-group-item-active {
background-color: $list-group-hover-bg !important;
border-left: 3px solid $primary !important;
margin-inline-start: -2px; /** compensate for the border mark, minus 1px (regular border) */
}
</style>