owltide/components/SelectDropdown.vue
Hornwitser 27c4720328 Fix changes not being detected in SelectDropdown
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.
2025-06-29 20:26:32 +02:00

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>