245 lines
5.7 KiB
Vue
245 lines
5.7 KiB
Vue
|
<template>
|
||
|
<div class="input">
|
||
|
<input
|
||
|
type="text"
|
||
|
v-model="inputText"
|
||
|
ref="input"
|
||
|
@keydown.up="keyUp"
|
||
|
@keydown.down="keyDown"
|
||
|
@keydown.enter="toggleActive"
|
||
|
@keydown.backspace="keyBackspace"
|
||
|
>
|
||
|
<button
|
||
|
@click="manualToggle()"
|
||
|
@keydown.up="keyUp"
|
||
|
@keydown.down="keyDown"
|
||
|
@keydown.enter.space="dropdownKey"
|
||
|
>
|
||
|
v
|
||
|
</button>
|
||
|
<div
|
||
|
v-if="showDropdown"
|
||
|
class="dropdown"
|
||
|
>
|
||
|
<ul class="options">
|
||
|
<li
|
||
|
v-for="entity of matchingEntities"
|
||
|
:class="{ active: activeId === entity.id, selected: selectedIds.has(entity.id) }"
|
||
|
@click="activeId = entity.id; toggleActive($event)"
|
||
|
tabindex="-1"
|
||
|
>
|
||
|
{{ entity.name }}
|
||
|
</li>
|
||
|
<li v-if="matchingEntities.length === 0">
|
||
|
no results
|
||
|
</li>
|
||
|
</ul>
|
||
|
</div>
|
||
|
</div>
|
||
|
</template>
|
||
|
|
||
|
<script lang="ts" setup>
|
||
|
import type { ApiEntity } from '~/shared/types/api';
|
||
|
import type { Id } from '~/shared/types/common';
|
||
|
import type { ClientEntity } from '~/utils/client-entity';
|
||
|
|
||
|
const selectedIds = defineModel<Set<Id>>({ required: true });
|
||
|
const activeId = ref<Id>();
|
||
|
const props = defineProps<{
|
||
|
entities: ClientMap<ClientEntity<ApiEntity> & { name?: string }>,
|
||
|
multi: boolean,
|
||
|
}>();
|
||
|
|
||
|
// Reset manualToggle if the focus leaves this component and doesn't come back.
|
||
|
const focusTimer = ref<ReturnType<typeof setTimeout>>();
|
||
|
function focusout() {
|
||
|
focusTimer.value = setTimeout(() => {
|
||
|
manualState.value = "closed";
|
||
|
}, 0);
|
||
|
}
|
||
|
function focusin() {
|
||
|
clearTimeout(focusTimer.value);
|
||
|
}
|
||
|
function esc() {
|
||
|
manualState.value = undefined;
|
||
|
}
|
||
|
function windowBlur() {
|
||
|
clearTimeout(focusTimer.value);
|
||
|
}
|
||
|
onMounted(() => {
|
||
|
window.addEventListener("blur", windowBlur);
|
||
|
});
|
||
|
onUnmounted(() => {
|
||
|
window.removeEventListener("blur", windowBlur);
|
||
|
});
|
||
|
|
||
|
defineExpose({
|
||
|
activeId,
|
||
|
focusin,
|
||
|
focusout,
|
||
|
esc,
|
||
|
});
|
||
|
|
||
|
const inputText = ref("");
|
||
|
const manualState = ref<"open" | "closed">();
|
||
|
const showDropdown = computed(() => {
|
||
|
if (manualState.value) {
|
||
|
return manualState.value === "open";
|
||
|
}
|
||
|
return Boolean(inputText.value)
|
||
|
});
|
||
|
|
||
|
function manualToggle() {
|
||
|
if (showDropdown.value) {
|
||
|
manualState.value = "closed";
|
||
|
activeId.value = undefined;
|
||
|
} else {
|
||
|
manualState.value = "open";
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function dropdownKey(event: KeyboardEvent) {
|
||
|
if (showDropdown.value) {
|
||
|
toggleActive(event);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function ensureActive(match: "first" | "last") {
|
||
|
const active = props.entities.get(activeId.value!);
|
||
|
if (!active || !active.name || !fuzzyMatch(inputText.value, active.name)) {
|
||
|
// Set active id to the first match in the chosen direction
|
||
|
const entities = match === "first" ? matchingEntities.value : [...matchingEntities.value].reverse();
|
||
|
for (const entity of entities) {
|
||
|
if (entity.name && fuzzyMatch(inputText.value, entity.name)) {
|
||
|
activeId.value = entity.id;
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
// No matches
|
||
|
activeId.value = undefined;
|
||
|
}
|
||
|
}
|
||
|
watch(inputText, () => {
|
||
|
if (manualState.value === "closed") {
|
||
|
manualState.value = undefined;
|
||
|
}
|
||
|
if (!inputText.value) {
|
||
|
return;
|
||
|
}
|
||
|
ensureActive("first");
|
||
|
})
|
||
|
function keyUp(event: KeyboardEvent) {
|
||
|
if (activeId.value === undefined) {
|
||
|
ensureActive("last");
|
||
|
event.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
const index = matchingEntities.value.findIndex(entity => entity.id === activeId.value);
|
||
|
if (index !== -1 && index !== 0) {
|
||
|
activeId.value = matchingEntities.value[index - 1].id;
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
function keyDown(event: KeyboardEvent) {
|
||
|
if (activeId.value === undefined) {
|
||
|
ensureActive("first");
|
||
|
event.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
const index = matchingEntities.value.findIndex(entity => entity.id === activeId.value);
|
||
|
if (index !== -1 && index !== matchingEntities.value.length) {
|
||
|
activeId.value = matchingEntities.value[index + 1].id;
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
const inputElement = useTemplateRef("input");
|
||
|
function keyBackspace(event: KeyboardEvent) {
|
||
|
if (
|
||
|
inputElement.value?.selectionStart === 0
|
||
|
&& inputElement.value?.selectionEnd === 0
|
||
|
) {
|
||
|
const lastId = [...selectedIds.value].slice(-1)[0];
|
||
|
if (lastId === undefined) {
|
||
|
return;
|
||
|
}
|
||
|
if (props.multi) {
|
||
|
selectedIds.value.delete(lastId);
|
||
|
} else {
|
||
|
selectedIds.value = new Set();
|
||
|
}
|
||
|
const entity = props.entities.get(lastId);
|
||
|
inputText.value = `${entity?.name ?? lastId}${inputText.value ? " " : ""}${inputText.value}`;
|
||
|
}
|
||
|
}
|
||
|
function toggleActive(event: MouseEvent | KeyboardEvent) {
|
||
|
if (activeId.value !== undefined) {
|
||
|
if (props.multi) {
|
||
|
if (selectedIds.value.has(activeId.value)) {
|
||
|
selectedIds.value.delete(activeId.value);
|
||
|
} else {
|
||
|
selectedIds.value.add(activeId.value);
|
||
|
}
|
||
|
} else {
|
||
|
selectedIds.value = new Set([activeId.value]);
|
||
|
}
|
||
|
if (!props.multi || !event.ctrlKey) {
|
||
|
manualState.value = undefined;
|
||
|
activeId.value = undefined;
|
||
|
inputText.value = "";
|
||
|
}
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const fuzzyMatch = useFuzzyMatch();
|
||
|
const stringSort = useStringSort();
|
||
|
const matchingEntities = computed(() => {
|
||
|
let entities = [...props.entities.values()];
|
||
|
if (inputText.value) {
|
||
|
entities = entities.filter(entity => entity.name && fuzzyMatch(inputText.value, entity.name));
|
||
|
}
|
||
|
return entities.sort((a, b) => stringSort(a.name ?? "", b.name ?? ""));
|
||
|
});
|
||
|
</script>
|
||
|
|
||
|
<style scoped>
|
||
|
.input {
|
||
|
display: flex;
|
||
|
flex-grow: 1;
|
||
|
}
|
||
|
|
||
|
input {
|
||
|
width: 2rem;
|
||
|
flex-grow: 1;
|
||
|
}
|
||
|
.dropdown {
|
||
|
position: absolute;
|
||
|
z-index: 1;
|
||
|
background: Canvas;
|
||
|
color: CanvasText;
|
||
|
left: 0;
|
||
|
right: 0;
|
||
|
top: 100%;
|
||
|
max-height: 50lvh;
|
||
|
overflow-y: auto;
|
||
|
}
|
||
|
.options {
|
||
|
list-style: none;
|
||
|
padding-inline-start: 0;
|
||
|
}
|
||
|
.options li:focus,
|
||
|
.options .active {
|
||
|
color: Canvas;
|
||
|
background-color: CanvasText;
|
||
|
}
|
||
|
.options .selected {
|
||
|
color: color-mix(in oklab, CanvasText, orange 30%);
|
||
|
background-color: color-mix(in oklab, Canvas, orange 30%);
|
||
|
}
|
||
|
.options li:focus.selected,
|
||
|
.options .active.selected {
|
||
|
color: color-mix(in oklab, Canvas, orange 30%);
|
||
|
background-color: color-mix(in oklab, CanvasText, orange 30%);
|
||
|
}
|
||
|
</style>
|