Files
KanbanGit/resources/js/components/projects/UserSelectDropdown.vue
T
Twyco ad3597694d
linter / quality (pull_request) Successful in 1m35s
tests / ci (8.4) (pull_request) Successful in 2m27s
Add searchable ticket property dropdowns
2026-07-18 22:10:04 +02:00

221 lines
7.8 KiB
Vue

<script setup lang="ts">
import { Check, ChevronDown, Search, UserRound } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UserSelectDropdownEmits, UserSelectDropdownProps } from '@/types';
const props = withDefaults(defineProps<UserSelectDropdownProps>(), {
emptyLabel: 'Unassigned',
});
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 normalizedSearchQuery = computed(() =>
searchQuery.value.trim().toLocaleLowerCase(),
);
const selectedUser = computed(
() =>
props.users.find((user) => String(user.id) === normalizedValue.value) ??
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 => {
if (!props.editable || value === normalizedValue.value) {
return;
}
const nextValue = value === '' ? '' : Number(value);
emit('update:modelValue', nextValue);
emit('commit', nextValue);
};
</script>
<template>
<DropdownMenu v-if="editable" v-model:open="isOpen">
<DropdownMenuTrigger as-child>
<button
type="button"
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"
:aria-label="`Select ${label}`"
>
<UserAvatar
v-if="selectedUser"
:user="selectedUser"
class="size-6 shrink-0"
/>
<span
v-else
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="min-w-0 flex-1 truncate">
{{ selectedUser?.name ?? emptyLabel }}
</span>
<ChevronDown
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
:side-offset="6"
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
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="
normalizedValue === ''
? 'bg-primary/[0.055] text-primary'
: ''
"
@select="selectUser('')"
>
<span
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="min-w-0 flex-1 truncate font-medium">
{{ emptyLabel }}
</span>
<Check
v-if="normalizedValue === ''"
class="text-primary size-4 shrink-0"
/>
</DropdownMenuItem>
<DropdownMenuItem
v-for="user in visibleUsers"
: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="
normalizedValue === String(user.id)
? 'bg-primary/[0.055] text-primary'
: ''
"
@select="selectUser(String(user.id))"
>
<UserAvatar :user="user" class="size-7 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">
{{ user.name }}
</span>
<span
v-if="user.email"
class="text-muted-foreground block truncate text-[11px]"
>
{{ user.email }}
</span>
</span>
<Check
v-if="normalizedValue === String(user.id)"
class="text-primary size-4 shrink-0"
/>
</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>
</DropdownMenu>
<div
v-else
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
>
<UserAvatar
v-if="selectedUser"
:user="selectedUser"
class="size-6 shrink-0"
/>
<span
v-else
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="truncate">{{ selectedUser?.name ?? emptyLabel }}</span>
</div>
</template>