The selection of locations, events, roles, shifts and users using the native <select> element makes for awkward and difficult interactions. Add an alternative select control that fixes the issues with the poor handling and navigation of the control when having many options. The custom select component can handle the selection of either one or many entity from a ClientMap of entiteis with a name. Typing into the text box searches the entities by name, arrow keys can navigate and enter confirms the chosen entity by toggling it's presence in the selection.
244 lines
5.7 KiB
Vue
244 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>
|