When modifying the set instead of replacing it with a new set the change detection logic in Vue.js doesn't properly propagate the change, causing certain computed properties that depend on them to go stale. Fix by creating a new set here, which will emit a modelValue:update event which will propagate through the v-model bindings.
244 lines
5.9 KiB
Vue
244 lines
5.9 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 = new Set(selectedIds.value).difference(new Set([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 = new Set(selectedIds.value).difference(new Set([activeId.value]));
|
|
} else {
|
|
selectedIds.value = new Set(selectedIds.value).union(new Set([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>
|