Add searchable ticket property dropdowns
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user