Add searchable ticket property dropdowns
linter / quality (pull_request) Successful in 1m35s
tests / ci (8.4) (pull_request) Successful in 2m27s

This commit is contained in:
2026-07-18 22:10:04 +02:00
parent 67b446134b
commit ad3597694d
4 changed files with 161 additions and 8 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'; import { Check, ChevronDown, Search } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -14,17 +14,63 @@ import type {
const props = withDefaults(defineProps<PropertySelectDropdownProps>(), { const props = withDefaults(defineProps<PropertySelectDropdownProps>(), {
disabled: false, disabled: false,
searchable: false,
}); });
const emit = defineEmits<PropertySelectDropdownEmits>(); const emit = defineEmits<PropertySelectDropdownEmits>();
const maximumVisibleOptions = 12;
const isOpen = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchQuery = ref('');
const normalizedValue = computed(() => String(props.modelValue ?? '')); const normalizedValue = computed(() => String(props.modelValue ?? ''));
const normalizedSearchQuery = computed(() =>
searchQuery.value.trim().toLocaleLowerCase(),
);
const selectedOption = computed( const selectedOption = computed(
() => () =>
props.options.find( props.options.find(
(option) => String(option.value) === normalizedValue.value, (option) => String(option.value) === normalizedValue.value,
) ?? null, ) ?? null,
); );
const visibleOptions = computed(() => {
const query = normalizedSearchQuery.value;
return props.options
.filter((option) => {
if (!props.searchable || query === '') {
return true;
}
return [option.label, option.description].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
})
.slice(0, maximumVisibleOptions);
});
watch(isOpen, (open) => {
searchQuery.value = '';
if (open && props.searchable) {
void nextTick(() => searchInput.value?.focus());
}
});
const focusSearch = (event: Event): void => {
if (!props.searchable) {
return;
}
event.preventDefault();
void nextTick(() => searchInput.value?.focus());
};
const handleSearchKeydown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' && event.key !== 'Tab') {
event.stopPropagation();
}
};
const selectOption = (value: number | string): void => { const selectOption = (value: number | string): void => {
if ( if (
@@ -41,7 +87,7 @@ const selectOption = (value: number | string): void => {
</script> </script>
<template> <template>
<DropdownMenu v-if="editable"> <DropdownMenu v-if="editable" v-model:open="isOpen">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<button <button
type="button" type="button"
@@ -62,9 +108,28 @@ const selectOption = (value: number | string): void => {
align="end" align="end"
:side-offset="6" :side-offset="6"
class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1" class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1"
@open-auto-focus="focusSearch"
> >
<div
v-if="searchable"
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
@click.stop
@pointerdown.stop
>
<Search class="text-muted-foreground size-3.5 shrink-0" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
:aria-label="`Search ${label}`"
@keydown="handleSearchKeydown"
/>
</div>
<DropdownMenuItem <DropdownMenuItem
v-for="option in options" v-for="option in visibleOptions"
:key="String(option.value)" :key="String(option.value)"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2" class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class=" :class="
@@ -90,6 +155,13 @@ const selectOption = (value: number | string): void => {
class="text-primary size-4 shrink-0" class="text-primary size-4 shrink-0"
/> />
</DropdownMenuItem> </DropdownMenuItem>
<p
v-if="visibleOptions.length === 0"
class="text-muted-foreground px-2 py-4 text-center text-xs"
>
No matching {{ label.toLocaleLowerCase() }} found.
</p>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronDown, UserRound } from 'lucide-vue-next'; import { Check, ChevronDown, Search, UserRound } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { import {
DropdownMenu, DropdownMenu,
@@ -15,13 +15,65 @@ const props = withDefaults(defineProps<UserSelectDropdownProps>(), {
}); });
const emit = defineEmits<UserSelectDropdownEmits>(); const emit = defineEmits<UserSelectDropdownEmits>();
const maximumVisibleOptions = 12;
const isOpen = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchQuery = ref('');
const normalizedValue = computed(() => String(props.modelValue ?? '')); const normalizedValue = computed(() => String(props.modelValue ?? ''));
const normalizedSearchQuery = computed(() =>
searchQuery.value.trim().toLocaleLowerCase(),
);
const selectedUser = computed( const selectedUser = computed(
() => () =>
props.users.find((user) => String(user.id) === normalizedValue.value) ?? props.users.find((user) => String(user.id) === normalizedValue.value) ??
null, null,
); );
const showEmptyOption = computed(
() =>
normalizedSearchQuery.value === '' ||
props.emptyLabel
.toLocaleLowerCase()
.includes(normalizedSearchQuery.value),
);
const visibleUsers = computed(() => {
const query = normalizedSearchQuery.value;
const maximumVisibleUsers = Math.max(
0,
maximumVisibleOptions - (showEmptyOption.value ? 1 : 0),
);
return props.users
.filter((user) => {
if (query === '') {
return true;
}
return [user.name, user.email].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
})
.slice(0, maximumVisibleUsers);
});
watch(isOpen, (open) => {
searchQuery.value = '';
if (open) {
void nextTick(() => searchInput.value?.focus());
}
});
const focusSearch = (event: Event): void => {
event.preventDefault();
void nextTick(() => searchInput.value?.focus());
};
const handleSearchKeydown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' && event.key !== 'Tab') {
event.stopPropagation();
}
};
const selectUser = (value: string): void => { const selectUser = (value: string): void => {
if (!props.editable || value === normalizedValue.value) { if (!props.editable || value === normalizedValue.value) {
@@ -36,7 +88,7 @@ const selectUser = (value: string): void => {
</script> </script>
<template> <template>
<DropdownMenu v-if="editable"> <DropdownMenu v-if="editable" v-model:open="isOpen">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<button <button
type="button" type="button"
@@ -67,8 +119,27 @@ const selectUser = (value: string): void => {
align="end" align="end"
:side-offset="6" :side-offset="6"
class="w-(--reka-dropdown-menu-trigger-width) min-w-64 rounded p-1" class="w-(--reka-dropdown-menu-trigger-width) min-w-64 rounded p-1"
@open-auto-focus="focusSearch"
> >
<div
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
@click.stop
@pointerdown.stop
>
<Search class="text-muted-foreground size-3.5 shrink-0" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
:aria-label="`Search ${label}`"
@keydown="handleSearchKeydown"
/>
</div>
<DropdownMenuItem <DropdownMenuItem
v-if="showEmptyOption"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2" class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class=" :class="
normalizedValue === '' normalizedValue === ''
@@ -92,7 +163,7 @@ const selectUser = (value: string): void => {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-for="user in users" v-for="user in visibleUsers"
:key="user.id" :key="user.id"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2" class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class=" :class="
@@ -119,6 +190,13 @@ const selectUser = (value: string): void => {
class="text-primary size-4 shrink-0" class="text-primary size-4 shrink-0"
/> />
</DropdownMenuItem> </DropdownMenuItem>
<p
v-if="!showEmptyOption && visibleUsers.length === 0"
class="text-muted-foreground px-2 py-4 text-center text-xs"
>
No matching {{ label.toLocaleLowerCase() }} found.
</p>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+2
View File
@@ -916,6 +916,7 @@ const formatDateTime = (value: string): string => {
:editable="canEditTicket" :editable="canEditTicket"
:disabled="isStatusMoving" :disabled="isStatusMoving"
label="Status" label="Status"
:searchable="true"
@commit="moveTicketStatus" @commit="moveTicketStatus"
/> />
<InlineSaveStatus <InlineSaveStatus
@@ -984,6 +985,7 @@ const formatDateTime = (value: string): string => {
:options="ticketPriorityOptions" :options="ticketPriorityOptions"
:editable="canEditTicket" :editable="canEditTicket"
label="Priority" label="Priority"
:searchable="true"
@commit=" @commit="
commitSelectField('ticket_priority_id') commitSelectField('ticket_priority_id')
" "
@@ -12,6 +12,7 @@ export type PropertySelectDropdownProps = {
label: string; label: string;
modelValue: PropertySelectDropdownValue; modelValue: PropertySelectDropdownValue;
options: PropertySelectOption[]; options: PropertySelectOption[];
searchable?: boolean;
}; };
export type PropertySelectDropdownEmits = { export type PropertySelectDropdownEmits = {